Who owns Charlottesville?
Mapping the city's biggest property owners
Every afternoon, @everysalecville posts details of the real estate transactions of the past day. Often, the buyer is a Limited Liability Company, or LLC; recent buyers include "YELLOW SHIRT LLC", "DADDY RABBIT, INC", and "ASPIRING DEVELOPMENTS, LLC". Which of these companies own the most land in Charlottesville, and who owns the companies?
We can look up real estate tranactions from the city Open Data Portal and identify the top property owners by owner name or address. In some cases, multiple owner names share the same owner address—often a business address or Post Office box—so we group properties by owner address. For example, 148 properties list their owner address as "224 14TH ST NW", with owner names like "400 PRESTON AVENUE LLC", "DOGWOOD PROPERTIES OF C'VILLE LLC", and "WP MONTEBELLO, LLC". These owning entities all seem to be associated with Woodard Properties, so we label them as part of this entity.
Note: I'm excluding tax-exempt parcels from this post, which is why we don't see much property owned by the city or UVA here.
import datetime
# Hack: Ignore shapely/geos version mismatch warning
import warnings
warnings.simplefilter("ignore")
import branca
import folium
import palettable
import pandas as pd
from google.cloud import bigquery
bq = bigquery.Client()
pd.set_option("display.max_colwidth", None)
# Fetch data from BigQuery
holdings = bq.query(
"""
with parcels as (
select
parcelnumb as parcelnumber,
assessment,
ownername,
case
when owneraddre = 'MSC BOX 5186' then 'P O BOX 5306'
when owneraddre = '3056 BERKMAR DRIVE' then '3056 BERKMAR DR'
else owneraddre
end as owneraddress,
st_geogfromgeojson(geometry) as geometry,
from `cvilledata.cville_open_data.parcel_area_details`
where owneraddre is not null
and filetype not in ('E')
)
select
owneraddress,
array_agg(distinct ownername ignore nulls order by ownername) as ownernames,
sum(assessment) as assessment,
st_area(st_union_agg(geometry)) as area,
count(*) as count,
from parcels
group by owneraddress
order by count desc
"""
).result().to_dataframe()
parcels = bq.query(
"""
with parcels as (
select
parcelnumb as parcelnumber,
streetnumb as streetnumber,
streetname,
concat(coalesce(streetnumb, '?'), ' ', streetname) as address,
assessment,
ownername,
case
when owneraddre = 'MSC BOX 5186' then 'P O BOX 5306'
when owneraddre = '3056 BERKMAR DRIVE' then '3056 BERKMAR DR'
else owneraddre
end as owneraddress,
st_geogfromgeojson(geometry) as geometry,
from `cvilledata.cville_open_data.parcel_area_details`
where owneraddre is not null
and filetype not in ('E')
), lastsold as (
select
parcelnumb as parcelnumber,
max(saledate) as saledate,
from `cvilledata.cville_open_data.real_estate_sales`
where saleamount > 0
group by parcelnumb
)
select
parcels.*,
lastsold.saledate as lastsold,
from parcels
left join lastsold on parcels.parcelnumber = lastsold.parcelnumber
where owneraddress in unnest(@owneraddresses)
""",
job_config=bigquery.QueryJobConfig(
query_parameters=[bigquery.ArrayQueryParameter("owneraddresses", "STRING", holdings[:10].owneraddress.tolist())]
),
).result().to_geodataframe()
# Map owner addresses to business entities
ADDRESS_TO_ENTITY_NAME = {
"810 CATALPA CT": "Neighborhood Investments",
"224 14TH ST NW": "Woodard Properties",
"PO BOX 911": "City of Charlottesville",
"360 ARDWOOD RD": "Silk Purse Properties",
"P O BOX 400884": "Rector & Visitors of UVA",
"977 SEMINOLE TR STE 329": "Evening Properties",
"PO BOX 1405": "CRHA",
"P O BOX 1414": "CBS Rentals",
"P O BOX 5306": "MSC UVA",
"P O BOX 1467": "Riverbend Development",
"1134 E HIGH ST": "Alcova Properties",
"1500 AMHERST ST #3": "Real Property Management",
"142 S PANTOPS DR": "Southern Development Homes",
"3056 BERKMAR DR": "Wade Rentals",
"1001 E MARKET ST STE 102": "Community Services Housing",
"400 LOCUST AVE STE 3": "BMC Holdings Group",
"300 2ND ST NE": "Milestone Partners",
"2000 BENTIVAR DR": "Binx Properties",
"2088 UNION ST STE 1": "Allan Cadgene",
"P O BOX 5526": "Great Eastern Management Company",
"201 15TH ST NW STE 1A": "Veliky Rentals",
"P O BOX 400218": "University of Virginia Foundation",
"P O BOX 7136": "INV Group",
"P O BOX 9035": "Pepsi-Cola Central Virginia",
}
holdings["ownerentity"] = holdings.owneraddress.apply(lambda owneraddress: ADDRESS_TO_ENTITY_NAME.get(owneraddress, owneraddress))
parcels["ownerentity"] = parcels.owneraddress.apply(lambda owneraddress: ADDRESS_TO_ENTITY_NAME.get(owneraddress, owneraddress))
# Stringify dates for compatibility with folium
parcels["lastsold"] = parcels.lastsold.apply(lambda date: datetime.datetime.strftime(date, "%Y-%m-%d") if date else "")
Here's the complete list of owner names associated with Woodard Properties via its business address:
holdings.ownernames[:1].tolist()
And here are the top 20 property owners in the city, ranked by number of parcels owned. Some business entities, like Silk Purse Properties or Evening Properties, use a single LLC to manage all their properties. Others use a range of LLCs, some named by property like "114 CARROLLTON TERRACE, LLC" and others with less revealing names like "MISCELLANEOUS PROPERTIES, LLC".
Note: owner names and addresses are public records from the city open data portal, but owner entities are my best guesses at the person or corporate entity that owns the properties. I looked up owner entities by Googling owner and property addresses and cross-checking property holdings with corporate rental portals. I've tried to be thorough but may have made mistakes, and I haven't been able to track down the owner of Wertland Commons, which appears to be registered at a residence.
It's also possible that grouping owners by address combines entities that should be treated as distinct. For example, Woodard Properties acquired Dogwood Housing years ago, so both entities share an address. But Woodard committed to continuing Dogwood's mission of providing affordable housing, so it might be reasonable to treat the two entities as separate.
holdings[["owneraddress", "ownernames", "ownerentity", "count"]][:20]
Next, we can map the properties of the largest groups. Here's an interactive map of the top 10 property owners in the city; hover over a parcel to see its owner, current assessment, etc. Some groups specialize in student apartments, and most of their properties are near UVA. MSC mostly follows this pattern, but it also appears to own the Mountain View Mobile Home Park off Avon St. Neighborhood Investments also owns some student apartments near campus, as well as a number of townhomes on Longwood Dr. Silk Road Properties, owned by Jeremy Caplin, owns dozens of rentals in the 10th and Page neighborhood, which are "offered them as affordable rentals for working families, while trying to slow displacement and gentrification". And Woodard Properties, the largest property owner by number of parcels, owns properties all over the city, including a growing collection in Fifeville that now includes much of the land between King St and Elm St.
entities = holdings[:10].ownerentity.tolist()
palette = palettable.colorbrewer.qualitative.Set3_10
def style(shape):
ownerentity = shape["properties"]["ownerentity"]
fill_color = palette.hex_colors[entities.index(ownerentity)]
return {
"color": "black",
"weight": 0.5,
"fillColor": fill_color,
"fillOpacity": 1,
}
map_ = folium.Map(
location=[38.04, -78.49],
tiles="cartodbpositron",
zoom_start=13.55,
zoom_delta=0.5,
)
folium.GeoJson(
parcels[["ownerentity", "ownername", "assessment", "address", "lastsold", "geometry"]],
tooltip=folium.GeoJsonTooltip(fields=["ownerentity", "ownername", "assessment", "address", "lastsold"], labels=True),
style_function=style,
).add_to(map_)
# Adapted from https://github.com/mrcagney/examples_folium/blob/develop/notebooks/categorical_legend.ipynb
def add_categorical_legend(map_, title, colors, labels):
"""
Given a Folium map, add to it a categorical legend with the given title, colors, and corresponding labels.
The given colors and labels will be listed in the legend from top to bottom.
Return the resulting map.
Based on `this example <http://nbviewer.jupyter.org/gist/talbertc-usgs/18f8901fc98f109f2b71156cf3ac81cd>`_.
"""
# Error check
if len(colors) != len(labels):
raise ValueError("colors and labels must have the same length.")
color_by_label = dict(zip(labels, colors))
# Make legend HTML
template = f"""
{{% macro html(this, kwargs) %}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id='maplegend' class='maplegend'>
<div class='legend-title'>{title}</div>
<div class='legend-scale'>
<ul class='legend-labels'>
"""
for label, color in color_by_label.items():
template += f"<li><span style='background:{color}'></span>{label}</li>"
template += """
</ul>
</div>
</div>
</body>
</html>
<style type='text/css'>
.maplegend {
position: absolute;
z-index:9999;
background-color: rgba(255, 255, 255, 1);
border-radius: 5px;
border: 2px solid #bbb;
padding: 10px;
font-size:12px;
right: 10px;
bottom: 20px;
}
.maplegend .legend-title {
text-align: left;
margin-bottom: 5px;
font-weight: bold;
font-size: 90%;
}
.maplegend .legend-scale ul {
margin: 0;
margin-bottom: 5px;
padding: 0;
float: left;
list-style: none;
}
.maplegend .legend-scale ul li {
font-size: 80%;
list-style: none;
margin-left: 0;
line-height: 18px;
margin-bottom: 2px;
}
.maplegend ul.legend-labels li span {
display: block;
float: left;
height: 16px;
width: 30px;
margin-right: 5px;
margin-left: 0;
border: 0px solid #ccc;
}
.maplegend .legend-source {
font-size: 80%;
color: #777;
clear: both;
}
.maplegend a {
color: #777;
}
</style>
{% endmacro %}
"""
macro = branca.element.MacroElement()
macro._template = branca.element.Template(template)
map_.get_root().add_child(macro)
return map_
add_categorical_legend(map_, "Owners", palette.hex_colors, entities)
map_
Finally, we can check which entities have bought the most land in the city in recent years. The top buyer by parcel count is Stanley Martin Homes, which is tied to a stalled development off Monte Vista Ave. Next are Southern Development Homes, Woodard Properties, and Neighborhood Investments, all familiar from our list of top property owners above. There are also a few owner entities that I haven't looked up, like "620 WOODBROOK DR STE 6" and "200 GARRETT ST STE O". I'll leave that as an exercise for the hypothetical reader.
sales = bq.query(
"""
with parcels as (
select
parcelnumb as parcelnumber,
assessment,
ownername,
case
when owneraddre = 'MSC BOX 5186' then 'P O BOX 5306'
when owneraddre = '3056 BERKMAR DRIVE' then '3056 BERKMAR DR'
else owneraddre
end as owneraddress,
st_geogfromgeojson(geometry) as geometry,
from `cvilledata.cville_open_data.parcel_area_details`
where owneraddre is not null
and filetype not in ('E')
)
select
owneraddress,
array_agg(distinct ownername ignore nulls order by ownername) as ownernames,
sum(assessment) as assessment,
st_area(st_union_agg(parcels.geometry)) as area,
count(*) as count,
from parcels
join `cvilledata.cville_open_data.real_estate_sales` sales
on parcels.parcelnumber = sales.parcelnumb
and sales.saledate >= '2017-01-01' and sales.saledate < '2022-01-01'
and sales.saleamount > 0
group by owneraddress
order by assessment desc
"""
).result().to_dataframe()
sales["ownerentity"] = sales.owneraddress.apply(lambda owneraddress: ADDRESS_TO_ENTITY_NAME.get(owneraddress, owneraddress))
sales[["owneraddress", "ownernames", "ownerentity", "count"]].sort_values("count", ascending=False)[:10]
This is just a quick analysis, but hopefully it's been interesting. You can send corrections or suggestions to me at @whatthecarp, or do your own analysis and let me know about it. You can find source data from the city Open Data Portal, and if you enjoy SQL, I've mirrored the relevant tables to a public dataset on Google BigQuery.