This report provides a descriptive analysis of the Baltimore Real Property Information dataset from Open Baltimore. The dataset contains information about property records throughout Baltimore City, including recorded sale prices, sale or transfer dates, neighborhoods, ZIP codes, building characteristics, and other property information.
For this analysis, I focus on property records with recorded sale or transfer dates between 2016 and 2025 and recorded sale prices between $10,000 and $3 million. Records with missing sale prices, dates, or neighborhood information are excluded.
The purpose of this analysis is to examine patterns in Baltimore property records across time and location. The report looks at neighborhood price trends, yearly property activity, price ranges, ZIP-code differences, and the distribution of recorded property prices.
Data Source: Open Baltimore - Real Property Information
# -------------------- All Required Packages --------------------
library(data.table)
library(dplyr)
library(ggplot2)
library(scales)
library(lubridate)
library(plotly)
library(knitr)
# -------------------- Import the Dataset --------------------
filename <- "R_datafiles/Baltimore_Real_Property.csv"
df <- fread(
filename,
na.strings = c("", "NA"),
colClasses = list(character = "SALEDATE"))
# Convert the recorded sale date into an R date
date_text <- sprintf("%08d", as.integer(df$SALEDATE))
df$sale_date <- as.Date( date_text, format = "%m%d%Y")
# Extract the year from the recorded sale date
df$year <- year(df$sale_date)
# Convert recorded sale prices into numeric values
df$SALEPRIC <- as.numeric(
gsub("[,$]", "", as.character(df$SALEPRIC)))
# Standardize neighborhood names
df$NEIGHBOR <- toupper(trimws(df$NEIGHBOR))
# Keep records used in the analysis
df_clean <- df %>%
filter(
!is.na(SALEPRIC),
!is.na(year),
!is.na(NEIGHBOR),
NEIGHBOR != "",
NEIGHBOR != "UNKNOWN",
SALEPRIC >= 10000,
SALEPRIC <= 3000000,
year >= 2016,
year <= 2025 ) %>%
data.frame()
The data-cleaning process converts the sale or transfer dates into a format that R can recognize, extracts the year, converts sale prices into numeric values, and standardizes neighborhood names. The analysis is limited to records dated from 2016 through 2025 with recorded prices between $10,000 and $3 million.
# Create a table of basic descriptive statistics
descriptive_stats <- data.frame(
Statistic = c(
"Eligible Property Records",
"Number of Neighborhoods",
"Median Recorded Sale Price",
"Mean Recorded Sale Price",
"Minimum Recorded Sale Price",
"Maximum Recorded Sale Price" ),
Value = c(
comma(nrow(df_clean)),
comma(length(unique(df_clean$NEIGHBOR))),
dollar(median(df_clean$SALEPRIC)),
dollar(mean(df_clean$SALEPRIC)),
dollar(min(df_clean$SALEPRIC)),
dollar(max(df_clean$SALEPRIC))))
kable(
descriptive_stats,
caption = "Basic Descriptive Statistics")
| Statistic | Value |
|---|---|
| Eligible Property Records | 81,785 |
| Number of Neighborhoods | 266 |
| Median Recorded Sale Price | $185,000 |
| Mean Recorded Sale Price | $240,462 |
| Minimum Recorded Sale Price | $10,000 |
| Maximum Recorded Sale Price | $3,000,000 |
After applying the analysis filters, the dataset contains 81,785 eligible property records across 266 Baltimore neighborhoods. The median recorded sale price is $185,000, while the mean is approximately $240,462. The higher mean suggests that higher-priced properties pull the average above the median. Because the median is less affected by unusually expensive properties, it is used frequently throughout this analysis.
# -------------------- Visualization 1: Multiple Line Plot ----------------------------
# The selected Baltimore neighborhoods to compare
selected_neighborhoods <- c("CANTON", "HAMPDEN", "FRANKFORD", "BELAIR-EDISON")
# Calculate median sale price by neighborhood and year
new_df <- df_clean %>%
filter(NEIGHBOR %in% selected_neighborhoods) %>%
select(NEIGHBOR, year, SALEPRIC) %>%
group_by(NEIGHBOR, year) %>%
summarise(n = length(SALEPRIC), median_price = median(SALEPRIC, na.rm = TRUE), .groups = "drop") %>%
data.frame()
# Keep neighborhood-year groups with at least 30 property records
new_df <- new_df %>%
filter(n >=30)
# Neighborhood Colors
line_colors <- c("CANTON" = "#0072b2", "HAMPDEN" = "#009e73", "FRANKFORD" = "#e69f00", "BELAIR-EDISON" = "#cc79a7")
# Multiple Line Plot
p1 <- ggplot(new_df, aes(x = year, y = median_price, group = NEIGHBOR, color = NEIGHBOR)) +
# Adding a line showing the yearly median sale price for each neighborhood
geom_line(linewidth=1.3) +
# Adding points for each yearly median sale price
geom_point(shape=21, size=3.5, fill="white", stroke=1.2) +
# Chart title, subtitle, axis labels, legend title, and source
labs(title = "A City of Different Price Paths",
subtitle = "Median Recorded Sale Prices Across Four Baltimore Neighborhoods | 2016-2025",
x = "Year of Recorded Sale or Transfer",
y = "Median Recorded Sale Price ($)",
color = "Neighborhood",
caption = "Source: Open Baltimore | Real Property Information | 2016-2025 | Recorded prices $10K-$3M") +
scale_x_continuous(breaks = 2016:2025) +
scale_y_continuous(labels=label_dollar(scale=1e-3, suffix = "K")) +
scale_color_manual (values = line_colors) +
guides(color = guide_legend(nrow = 2, byrow = TRUE)) +
theme_light(base_size = 12) +
theme(plot.title = element_text(hjust = 0.5, face = "bold", size = 16, color = "#17324D"),
plot.subtitle = element_text(hjust = 0.5, size = 10, color = "#52687A"),
axis.title.x = element_text(face = "bold", size = 11),
axis.title.y = element_text(face = "bold", size = 11),
legend.position = "top",
legend.title = element_text(face = "bold", size = 10),
legend.text = element_text(size = 10),
panel.grid.minor = element_blank(),
plot.caption = element_text(hjust = 0, size = 8),
plot.margin = margin(15,20,15,20)
)
p1
The line chart shows clear differences in recorded sale-price patterns across the four selected Baltimore neighborhoods. Canton consistently has the highest median recorded sale prices throughout the period, followed by Hampden. Frankford remains below Canton and Hampden but shows an overall increase from 2016 through 2025. Belair-Edison has the lowest median recorded sale prices of the four neighborhoods, although its values also increase over the period. Overall, all four neighborhoods show higher median recorded sale prices in 2025 than in 2016, but the size and timing of these increases differ by neighborhood. This demonstrates that property-price trends are not uniform across Baltimore.
# ------------------ Visualization 2: Dual-Axis Chart ------------------------
# Calculate the number of property records and median sale price for each year
year_summary <- df_clean %>%
select(year, SALEPRIC) %>%
group_by(year) %>%
summarise(n = length(SALEPRIC), median_price = median(SALEPRIC, na.rm = TRUE), .groups = "drop") %>%
data.frame()
# Calculate a scaling factor so property counts and median sale prices can be shown on the same chart
scale_factor <- 0.8 * max(year_summary$n) / max(year_summary$median_price)
# Create the dual-axis chart
p2 <- ggplot(year_summary, aes(x = year)) +
# Adding bars showing the number of eligible property records each year
geom_bar(aes(y = n, fill = "Property Records"),
stat = "identity",
width = 0.7,
alpha = 0.85) +
# Add a line showing the median recorded sale price each year
geom_line(aes(y= median_price * scale_factor,
color = "Median Sale Price",
group = 1),
linewidth = 1.5) +
# Add points to highlight the yearly median sale prices
geom_point(aes(y = median_price * scale_factor),
shape = 21,
size = 3.5,
fill = "white",
color = "#E16B54",
stroke = 1.3) +
# Add the number of property records above each bar
geom_text(aes(y = n, label = comma(n)),
vjust = -1.8,
size = 3.2,
color = "#17324D",
fontface = "bold") +
# Chart title, subtitle, axis label, legend, and source
labs(title = "Price and Activity, Side by Side",
subtitle = "Baltimore Property Records and Median Recorded Sale Prices | 2016-2025",
x = "Year of Recorded Sale or Transfer",
fill = NULL,
color = NULL,
caption = "Source: Open Baltimore | Real Property Information | Recorded prices $10K-$3M") +
# Display year 2016 through 2025
scale_x_continuous(breaks = 2016:2025) +
# Left axis for property records and right axis median sale price
scale_y_continuous(name = "Number of Eligible Property Records",
labels = comma, limits = c(0,max(year_summary$n) * 1.2),
sec.axis = sec_axis(~./ scale_factor,
name = "Median Recorded Sale Price ($)", labels = label_dollar(scale = 1e-3, suffix = "K"))) +
# Set the color for property-record bars
scale_fill_manual(values = c("Property Records" = "#246A93")) +
# Set the color for median-sale-price line
scale_color_manual(values = c("Median Sale Price" = "#E16B54")) +
theme_light(base_size = 12) +
# Customize chart title, axes, legend, and spacing
theme(plot.title = element_text(hjust = 0.5, face = "bold", size = 16, color = "#17324D"),
plot.subtitle = element_text(hjust = 0.5, size = 10, color = "#52687A"),
axis.title.x = element_text(face = "bold", size = 11),
# Match left y-axis to the blue property record bars
axis.title.y.left = element_text(face = "bold", color = "#246A93", size = 11),
axis.text.y.left = element_text(color = "#246A93", size = 11),
# Match right y-axis to the coral median price line
axis.text.y.right = element_text(face = "bold", color = "#E16B54", size = 11),
legend.position = "top",
legend.title = element_blank(),
legend.text = element_text(size =10),
legend.direction = "horizontal",
panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank(),
plot.caption = element_text(hjust = 0, size = 8),
plot.margin = margin(15,20,15,20))
p2
The dual-axis chart compares the number of eligible property records with the median recorded sale price for each year. Property-record activity generally increased between 2016 and 2019 before falling in 2020. The number of eligible records then increased sharply in 2021 and remained relatively high through 2025. At the same time, the median recorded sale price shows an overall upward trend across the period. The chart helps show that changes in the number of property records do not always move in the same way as changes in median recorded sale prices. For example, the number of records changes substantially after 2020, while the median price follows a smoother overall upward pattern.
# ----------------- Visualization 3: Plotly Donut Chart ----------------------
# Divide property records into four recorded sale-price ranges
price_df <- df_clean %>%
mutate(
price_range = case_when(
SALEPRIC < 150000 ~ "$10K-$149K",
SALEPRIC < 300000 ~ "$150K-$299K",
SALEPRIC < 500000 ~ "$300K-$499K",
TRUE ~ "$500K-$3M" )) %>%
# Count the number of records in each price range
group_by(price_range) %>%
summarise(n = n(), .groups = "drop") %>%
data.frame()
# Calculate the percentage of records in each price range
price_df$percent_of_total <- round(100 * price_df$n / sum(price_df$n),1)
# Arrange the price ranges in logical order
price_df$price_range <- factor(
price_df$price_range,
levels = c(
"$10K-$149K",
"$150K-$299K",
"$300K-$499K",
"$500K-$3M"))
price_df <- price_df %>%
arrange(price_range)
# Create the Plotly donut chart
p3 <- plot_ly(
price_df,
labels = ~price_range,
values = ~n,
type = "pie",
hole = 0.55,
textposition = "outside",
textinfo = "label+percent",
hovertemplate = paste(
"Price Range: %{label}",
"<br>Property Records: %{value:,}",
"<br>Percent of Records: %{percent}",
"<extra></extra>")) %>%
layout(
title = list(
text = "Where Do Baltimore Property Prices Fall?"),
legend = list(
title = list(
text = "Recorded Sale Price")),
annotations = list(
list(
text = paste0(
"Total Records<br>",
comma(sum(price_df$n))), x = 0.5,y = 0.5, font = list(size = 14),showarrow = FALSE)))
p3
The donut chart shows that most eligible Baltimore property records fall within the lower and middle recorded sale-price ranges. Approximately 38.8% of the records have recorded sale prices between $10,000 and $149,999, while 36.4% fall between $150,000 and $299,999. Together, these two categories represent approximately 75.2% of the eligible property records. Higher recorded sale prices are less common, with about 17.0% of records between $300,000 and $499,999 and only about 7.8% at $500,000 or higher. Overall, the visualization shows that the majority of eligible property records in this analysis are concentrated below $300,000.
This visualization uses a heatmap to compare median recorded sale prices across Canton, Hampden, Frankford, and Belair-Edison from 2016 through 2025. Each cell represents the median recorded sale price for one neighborhood and year. The color intensity makes it easier to compare higher and lower price levels across neighborhoods and over time.
# -------------------- Visualization 4: Heatmap --------------------
# Arrange the neighborhoods in a order
new_df$NEIGHBOR <- factor(
new_df$NEIGHBOR,
levels = selected_neighborhoods)
# Create a heatmap of median recorded sale prices
p4 <- ggplot(
new_df,
aes(
x = year,
y = NEIGHBOR,
fill = median_price)) +
# Create one colored tile for each neighborhood and year
geom_tile(
color = "white",
linewidth = 0.7) +
# Display the median sale price inside each tile
geom_text(
aes(
label = dollar(
median_price,
scale = 1e-3,
suffix = "K",
accuracy = 1)), size = 3.5) +
# Chart title, axis labels, legend title, and source
labs(
title = "Baltimore Neighborhood Prices at a Glance",
subtitle = "Median Recorded Sale Price by Neighborhood and Year | 2016-2025",
x = "Year of Recorded Sale or Transfer",
y = "Neighborhood",
fill = "Median Sale Price",
caption = "Source: Open Baltimore | Real Property Information | Recorded prices $10K-$3M") +
# Display every year from 2016 through 2025
scale_x_continuous(breaks = 2016:2025) +
# Arrange the neighborhoods from top to bottom
scale_y_discrete(limits = rev(selected_neighborhoods)) +
# Use color intensity to represent median sale price
scale_fill_gradient(
low = "white",
high = "#246A93",
labels = label_dollar(
scale = 1e-3,
suffix = "K")) +
# Apply a clean chart theme
theme_minimal(base_size = 12) +
# Customize the chart appearance
theme(
plot.title = element_text(
hjust = 0.5,
face = "bold",
size = 16,
color = "#17324D"),
plot.subtitle = element_text(
hjust = 0.5,
size = 10,
color = "#52687A"),
axis.title = element_text(
face = "bold",
size = 11),
panel.grid = element_blank(),
plot.caption = element_text(
hjust = 0,
size = 8))
p4
The heatmap shows clear differences in recorded sale prices across the four neighborhoods. Canton generally has the highest median recorded sale prices, followed by Hampden, while Frankford and Belair-Edison remain at lower price levels. The increasing color intensity in later years also shows that median recorded sale prices generally increased over the 2016-2025 period. However, the differences between neighborhoods remain visible throughout the period. The heatmap reinforces the pattern shown in the first visualization while making the relative price differences easier to compare at a glance.
The final visualization compares the distribution of recorded sale prices across Canton, Hampden, Frankford, and Belair-Edison. A box plot is useful because it shows the median, the middle range of values, and the overall spread of recorded property prices within each neighborhood.
# -------------------- Visualization 5: Box Plot --------------------
# Keep property records from the four selected neighborhoods
box_df <- df_clean %>%
filter(NEIGHBOR %in% selected_neighborhoods) %>%
select(NEIGHBOR,SALEPRIC) %>%
data.frame()
# Arrange the neighborhoods in a order
box_df$NEIGHBOR <- factor(
box_df$NEIGHBOR,
levels = selected_neighborhoods)
# Box Plot
p5 <- ggplot(box_df,
aes(
x = NEIGHBOR,
y = SALEPRIC,
fill = NEIGHBOR)) +
# Display the distribution of recorded sale prices
geom_boxplot(
width = 0.65,
alpha = 0.85,
outlier.shape = NA) +
# Use the same neighborhood colors as Visualization 1
scale_fill_manual(values = line_colors) +
# Format the sale prices in thousands of dollars
scale_y_continuous(labels = label_dollar(scale = 1e-3,suffix = "K")) +
coord_cartesian(ylim = c(0, 750000)) +
# Chart title, axis labels, and source
labs(
title = "Four Neighborhoods, Four Price Distributions",
subtitle = "Distribution of Recorded Sale Prices | 2016-2025",
x = "Neighborhood",
y = "Recorded Sale Price ($)",
caption = "Source: Open Baltimore | Real Property Information | Outlier points hidden and Y-axis zoomed for readability") +
# Clean chart theme
theme_light(base_size = 12) +
# Customize chart appearance
theme(plot.title = element_text(
hjust = 0.5,
face = "bold",
size = 16,
color = "#17324D"),
plot.subtitle = element_text(
hjust = 0.5,
size = 10,
color = "#52687A"),
axis.title = element_text(face = "bold",size = 11),
legend.position = "none",
panel.grid.minor = element_blank(),
plot.caption = element_text(hjust = 0,size = 8))
p5
The box plot shows clear differences in the distribution of recorded sale prices across the four neighborhoods. Canton and Hampden are concentrated at higher price levels, while Frankford and Belair-Edison have lower median recorded sale prices. The height of each box represents the middle portion of property prices within that neighborhood, while the line inside each box represents the median. The differences in the size of the boxes and whiskers also show that the amount of price variation differs across neighborhoods. This visualization complements the earlier line chart because it shows the overall distribution of individual recorded property prices instead of only comparing yearly median values.
This analysis shows that Baltimore property records reveal substantial differences across neighborhoods, years, and price ranges. Median recorded sale prices generally increased between 2016 and 2025, although the level and timing of those increases differed across neighborhoods. Canton and Hampden consistently showed higher price levels than Frankford and Belair-Edison. The analysis also showed that most eligible property records were concentrated below $300,000, while records at $500,000 or more represented a much smaller share of the dataset.
Overall, the visualizations demonstrate that Baltimore does not have one uniform property-price pattern. Location plays an important role in recorded property prices, and neighborhoods can experience different price levels, distributions, and changes over time.