The market reality is that land prices are now so high that no one can afford to build anything that 'blends'. Much high density is a precondition for new housing. Without new housing, the cost of existing housing rises. California has finally reached the point where the state leaders recognized that the cost of housing was impairing statewide economic growth and have started pre-empting local zoning. Utah (a deep red state) has some to a similar conclusion, but a different solution: places that don't permit growth don't get any infrastructure investment.
Wednesday, October 21, 2026
Friday, October 16, 2026
Density Profiles of Cities
Playing around with my WeAloneOnEarth code, testing it against cities* I've lived in, started getting some interesting results:
Suggesting Phoenix is more like Salt Lake than Raleigh is counter-intuitive: aren't both Raleigh and Phoenix both sprawling sunbelt cities? Adding more cities makes it stranger:
Suggests that in some fundamental way, a surprising array of cities in the west are similar, and very different from places in the sprawling south. Adding more major southern cities only reinforces things:
Compare and contrast on some extreme cases is interesting: LA is its own thing, as different from Phoenix as Phoenix is from Atlanta.
Altogether, these two charts together suggest that we can describe every city in American using two numbers: a median population, and a k-value. Hypothetically, Cleveland would probably have a peak at 10^.37, with a K value of 3.5. In actuality, Cleveland peaks (minimally), but has a much higher K value than expected (k=4.79) such that it is much more like Atlanta(!) than Chicago and seems to be missing high-density tracts in the 10^(3.5-4) order of magnitude.
Next step may be to map this - if I was to arbitrarily declare 10^3 (1000 persons**/square kilometer) to be transit suitable densities, what would each of these cities look like, and how would that align to their transit networks.
*The term city is used casually here; the data is for census urbanized areas. A city as a legal and territorial jurisdiction isn't relevant in this context.
Thursday, October 15, 2026
A Portrait of a City as a Squiggly Line
I have loved the graphics from "A Portrait of the City as a Squiggly Line" from the from the "We Alone on Earth" blog for the past 15 years (!), and have long aspired to recreate it. With some extensive help from Claude, I have done so (R Code at the bottom).
The original (based on 2000 census data)
My recreation (2023 ACS data):
Changes: Atlanta given its own color.
I suspect that the creator of the original did some additional (visual) smoothing for the second chart.
R code follows:
#-------------------------------------------------------------------------------
# Population density experienced by residents, compared across eight 2020 Census Urban Areas:
# Chicago, IL--IN New York--Jersey City--Newark, NY--NJ
# Boston, MA--NH Seattle--Tacoma, WA
# Las Vegas--Henderson--Paradise, NV Atlanta, GA
#
# Universe: census tracts whose interior point falls inside the urban area
# boundary. Whole tracts are used (their full population and land area), so
# tracts straddling the edge are in or out as a unit.
#
# Density = tract population / tract LAND area (people per km²).
#
# Two charts:
# 1. Share of residents living at AT LEAST each density (log x-axis).
# Read: "x% of the urban area's people live in tracts at least this dense."
# 2. Number of people living in tracts within +/-15% of each density.
# Read: "this many people live at about this density."
#
# Both are computed directly from tracts -- no smoothing.
#
# Population: most recent ACS 5-year (tries newer vintages first, falls back).
# Land area: TIGER/Line cartographic boundary tracts (ALAND) for the same year.
# Urban areas: TIGER/Line 2020 Urban Areas (2020 criteria).
#
# Requires: tidycensus, tigris, sf, dplyr, ggplot2, scales, patchwork
# install.packages(c("tidycensus", "tigris", "sf", "dplyr", "ggplot2", "scales", "patchwork"))
# One-time API key setup (free key: https://api.census.gov/data/key_signup.html):
# tidycensus::census_api_key("YOUR_KEY", install = TRUE); readRenviron("~/.Renviron")
# ---------------------------------------------------------------------------
suppressPackageStartupMessages({
library(tidycensus)
library(tigris)
library(sf)
library(dplyr)
library(ggplot2)
library(scales)
})
options(tigris_use_cache = TRUE)
# --- settings --------------------------------------------------------------
# One row per urban area. `ua_name` must match the Census name exactly --
# note the double hyphen in multi-city names. If a name is off, the script
# stops and suggests close matches. `state` = the state(s) whose tracts to
# search; for a UA crossing a state line, add a second row with the same
# label and ua_name and the other state.
areas <- data.frame(
label = c("Los Angeles", "Bay Area",
"Chicago", "Chicago",
"New York", "New York",
"Boston", "Boston",
"Seattle", "Las Vegas", "Atlanta"),
ua_name = c("Los Angeles--Long Beach--Anaheim, CA", "San Francisco--Oakland, CA",
"Chicago, IL--IN", "Chicago, IL--IN",
"New York--Jersey City--Newark, NY--NJ", "New York--Jersey City--Newark, NY--NJ",
"Boston, MA--NH", "Boston, MA--NH",
"Seattle--Tacoma, WA", "Las Vegas--Henderson--Paradise, NV", "Atlanta, GA"),
state = c("CA", "CA",
"IL", "IN",
"NY", "NJ",
"MA", "NH",
"WA", "NV", "GA"),
stringsAsFactors = FALSE
)
ua_year <- 2023 # fallback TIGER vintage if the 2020-criteria download fails
# Line colors, keyed by label -- matplotlib's classic cycle (b, g, r, c, m,
# y, k), matching the reference figure, except Atlanta, which is pale blue
# here instead of repeating Los Angeles's blue.
line_cols <- c("Los Angeles" = "#0000FF",
"Bay Area" = "#008000",
"Chicago" = "#FF0000",
"New York" = "#00BFBF",
"Boston" = "#BF00BF",
"Seattle" = "#BFBF00",
"Las Vegas" = "#000000",
"Atlanta" = "#7FB2E5") # pale blue, to separate it from LA's blue
x_range <- c(10^2.5, 1e5) # x-axis limits for both charts, people/km².
# NULL = fit the data.
chart1_xmin <- 10^2.5 # chart 1 x-axis starts here (~316 people/km²)
chart2_xmin <- 10^2.5 # chart 2 x-axis starts here (~316 people/km²)
window_pct <- 15 # chart 2: count people within +/- this % of each density
chart2_log <- TRUE # chart 2 x-axis on a log scale (FALSE = linear)
grid_n <- 400 # chart 2: number of density points evaluated
panel_wh <- 2 # each chart's plot area is this many times wider than tall
years_to_try <- 2025:2022 # newest first; first one that answers wins
out_png <- "density_profiles.png" # both charts stacked, one figure
out_png1 <- "density_share_at_least.png" # chart 1 alone
out_png2 <- "density_people_within_window.png" # chart 2 alone
# --- download --------------------------------------------------------------
if (!nzchar(Sys.getenv("CENSUS_API_KEY")))
stop("No Census API key found. Run once:\n",
' tidycensus::census_api_key("YOUR_KEY", install = TRUE)\n',
' readRenviron("~/.Renviron")\n',
"Free key: https://api.census.gov/data/key_signup.html", call. = FALSE)
states <- unique(areas$state)
# Statewide tract pulls; the urban area filter happens spatially below.
pull_pop <- function(year) {
bind_rows(lapply(states, function(s)
get_acs(geography = "tract", variables = c(pop = "B01003_001"),
state = s, survey = "acs5", year = year, cache_table = TRUE)))
}
acs <- NULL
errs <- character(0)
for (yr in years_to_try) {
res <- tryCatch(pull_pop(yr), error = function(e) e)
if (inherits(res, "error")) {
errs[as.character(yr)] <- conditionMessage(res)
message(sprintf(" %d: %s", yr, conditionMessage(res)))
next
}
if (nrow(res) > 0) { acs <- res; acs_year <- yr; break }
}
if (is.null(acs)) stop("Every year failed. Errors:\n",
paste0(" ", names(errs), ": ", errs, collapse = "\n"),
call. = FALSE)
message(sprintf("Using ACS %d-%d 5-year estimates", acs_year - 4, acs_year))
# --- urban area boundaries -------------------------------------------------
# tigris only builds a valid download URL for the 2020-criteria urban areas
# as the full TIGER/Line file with year = 2020 + criteria = "2020" (no
# cartographic-boundary version). It's a one-time ~100 MB download, cached
# afterwards. The second attempt is a fallback for other tigris versions.
ua_attempts <- list(
function() urban_areas(year = 2020, criteria = "2020", progress_bar = FALSE),
function() urban_areas(year = ua_year, progress_bar = FALSE)
)
ua_all <- NULL
for (f in ua_attempts) {
ua_all <- tryCatch(f(), error = function(e) { message(" urban areas: ", conditionMessage(e)); NULL })
if (!is.null(ua_all)) break
}
if (is.null(ua_all)) stop("Could not download urban area boundaries (see messages above).",
call. = FALSE)
name_col <- grep("^NAME", names(ua_all), value = TRUE)[1] # NAME20 in 2020-based files
missing <- setdiff(areas$ua_name, ua_all[[name_col]])
if (length(missing) > 0) {
hints <- vapply(missing, function(m) {
key <- sub("[-, ].*$", "", m) # first word of the name
hits <- grep(key, ua_all[[name_col]], value = TRUE, ignore.case = TRUE)
paste0(" '", m, "' -- did you mean: ",
if (length(hits)) paste(head(hits, 5), collapse = " | ") else "(no match)")
}, character(1))
stop("Urban area name(s) not found:\n", paste(hints, collapse = "\n"), call. = FALSE)
}
# Equal-area projection so interior points and joins are planar-correct.
ua <- ua_all[ua_all[[name_col]] %in% areas$ua_name, name_col]
names(ua)[names(ua) == name_col] <- "ua_name"
ua <- st_transform(ua, 5070)
# --- tracts: land area + urban area assignment ----------------------------
# Same vintage as the ACS so tract GEOIDs match. ALAND excludes water.
tr <- do.call(rbind, lapply(states, function(s)
tracts(state = s, year = acs_year, cb = TRUE, progress_bar = FALSE)[, c("GEOID", "ALAND")])) |>
st_transform(5070)
# A tract belongs to an urban area if its interior point (always inside the
# polygon, unlike a centroid) falls within the boundary.
tract_ua <- suppressWarnings(st_point_on_surface(tr)) |>
st_join(ua, join = st_within, left = FALSE) |>
st_drop_geometry() |>
inner_join(distinct(areas, ua_name, area = label), by = "ua_name") |>
select(GEOID, ALAND, area)
df <- acs |>
inner_join(tract_ua, by = "GEOID") |>
transmute(area, GEOID, pop = estimate,
land_km2 = as.numeric(ALAND) / 1e6,
density = pop / land_km2)
n_in_ua <- nrow(df)
df <- df |> filter(!is.na(pop), pop > 0, land_km2 > 0) # log axis needs density > 0
if (n_in_ua > nrow(df))
message(sprintf(" %d urban-area tracts dropped (no population or no land area)",
n_in_ua - nrow(df)))
# --- summaries -------------------------------------------------------------
# Population-weighted median: half of residents live at lower density, half
# at higher. On chart 1 it's where each curve crosses 50%.
wmedian <- function(x, w) { o <- order(x); x[o][which(cumsum(w[o]) / sum(w) >= 0.5)[1]] }
stats <- df |>
group_by(area) |>
# med must come before pop: summarise() evaluates in order, and pop = sum(pop)
# would otherwise replace the per-tract weights with a single total.
summarise(tracts = n(),
med = wmedian(density, pop),
pop = sum(pop), .groups = "drop")
print(stats)
# Plot order and colors follow `areas` and `line_cols`.
lvl <- unique(areas$label)
pal <- line_cols[lvl]
if (anyNA(pal))
stop("No color in line_cols for: ", paste(lvl[is.na(pal)], collapse = ", "), call. = FALSE)
df <- df |> mutate(area = factor(area, levels = lvl))
src <- sprintf("Census tracts, ACS %d-%d 5-year estimates; land area from TIGER/Line",
acs_year - 4, acs_year)
# --- shared styling: matplotlib "classic" look -----------------------------
# White panel, full black box, ticks pointing inward, no gridlines, thin lines.
base_theme <- theme_bw(base_size = 12) +
theme(panel.grid = element_blank(),
panel.border = element_rect(colour = "black", fill = NA, linewidth = 0.6),
axis.ticks = element_line(colour = "black", linewidth = 0.4),
axis.ticks.length = unit(-4, "pt"), # inward ticks
axis.text = element_text(colour = "black", size = 11),
axis.text.x = element_text(margin = margin(t = 7)),
axis.text.y = element_text(margin = margin(r = 7)),
axis.title = element_text(size = 11),
plot.margin = margin(6, 14, 6, 6),
aspect.ratio = 1 / panel_wh) # panel height = width / panel_wh
x_lim <- if (is.null(x_range)) range(df$density) else x_range
log_x <- scale_x_log10(labels = label_log(), breaks = 10^(0:6), expand = c(0, 0))
# Minor log ticks, drawn inside the panel along the bottom like matplotlib's.
log_ticks <- annotation_logticks(sides = "b", outside = FALSE, colour = "black",
short = unit(2, "pt"), mid = unit(3, "pt"),
long = unit(4, "pt"), linewidth = 0.3)
line_w <- 0.95 # line thickness for both charts
# --- chart 1: share of residents living at AT LEAST each density ----------
# For each tract (sorted by density), the share of the urban area's population
# in tracts at that density or higher. Starts at 100% and steps down.
cum <- df |>
group_by(area) |>
arrange(density, .by_group = TRUE) |>
mutate(share_at_least = 100 * rev(cumsum(rev(pop))) / sum(pop)) |>
ungroup()
p1 <- ggplot(cum, aes(x = density, y = share_at_least, colour = area)) +
geom_step(direction = "vh", linewidth = line_w) +
scale_colour_manual(values = pal, name = NULL) +
log_x + log_ticks +
scale_y_continuous(breaks = seq(0, 100, 20), expand = c(0, 0)) +
# Zoom only: tracts below chart1_xmin still count toward the shares, so the
# curves enter at below 100% if some residents live at lower density.
coord_cartesian(xlim = c(chart1_xmin, x_lim[2]), ylim = c(0, 100)) +
labs(x = "People per sq. km.", y = "Percentage living at at least that density") +
base_theme +
# Boxed legend inside the top-right corner.
theme(legend.position = c(0.99, 0.98),
legend.justification = c(1, 1),
legend.background = element_rect(colour = "black", fill = "white", linewidth = 0.4),
legend.key = element_blank(),
legend.key.width = unit(2.4, "lines"),
legend.text = element_text(size = 13),
legend.margin = margin(4, 8, 4, 4))
# --- chart 2: people living within +/- window_pct of each density ---------
# Evaluate a grid of densities; at each one, sum the population of tracts
# whose density is within +/- window_pct of it. The window is relative, so
# it widens as density rises (at 15%: +/-150 at 1,000; +/-1,500 at 10,000).
rng <- x_lim
rng[1] <- chart2_xmin # no need to evaluate densities left of the axis
grid <- if (chart2_log) 10^seq(log10(rng[1]), log10(rng[2]), length.out = grid_n) else
seq(rng[1], rng[2], length.out = grid_n)
f <- window_pct / 100
win <- df |>
group_by(area) |>
group_modify(function(d, ...) {
inside <- outer(d$density, grid, function(x, g) abs(x - g) <= f * g)
data.frame(density = grid, people = colSums(inside * d$pop))
}) |>
ungroup()
p2 <- ggplot(win, aes(x = density, y = people, colour = area)) +
geom_line(linewidth = line_w) +
scale_colour_manual(values = pal, guide = "none") + # legend lives on chart 1
{if (chart2_log) log_x else scale_x_continuous(labels = label_comma(), expand = c(0, 0))} +
{if (chart2_log) log_ticks} +
scale_y_continuous(labels = label_number(big.mark = ""), breaks = breaks_extended(8),
expand = expansion(mult = c(0, 0.02))) +
coord_cartesian(xlim = rng) +
labs(x = "People per sq. km.",
y = sprintf("People living within %.1f%% of that density", window_pct)) +
base_theme
# --- output ----------------------------------------------------------------
fig_title <- "Density profiles of cities"
if (requireNamespace("patchwork", quietly = TRUE)) {
# Stack the two charts in one figure, like the reference.
fig <- patchwork::wrap_plots(p1, p2, ncol = 1) +
patchwork::plot_annotation(
title = fig_title,
caption = src,
theme = theme(plot.title = element_text(hjust = 0.5, size = 15),
plot.caption = element_text(colour = "grey40", size = 8)))
print(fig)
ggsave(out_png, fig, width = 10, height = 10.5, dpi = 300, bg = "white")
message("Saved ", out_png)
} else {
message("Install patchwork for the stacked figure: install.packages(\"patchwork\")")
print(p1); print(p2)
}
ggsave(out_png1, p1 + ggtitle(fig_title) + theme(plot.title = element_text(hjust = 0.5)),
width = 10, height = 5.8, dpi = 300, bg = "white")
ggsave(out_png2, p2 + ggtitle(fig_title) + theme(plot.title = element_text(hjust = 0.5)),
width = 10, height = 5.8, dpi = 300, bg = "white")
message("Saved ", out_png1, " and ", out_png2)
# ---------------------------------------------------------------------------
# Notes
# * Chart 1 is the complementary cumulative distribution: every curve starts
# at 100% on the left and falls to 0 at the urban area's densest tract. A curve
# further right means more residents live at higher density.
# * Chart 2 is raw counts, so bigger urban areas sit higher (New York's
# ~20 million people dwarf Las Vegas's ~2.2 million). For shape
# comparison, divide `people` by each urban area's total population.
# * Multi-state urban areas (Chicago, New York, Boston) have one row per
# state in `areas`; all their tracts land in the same curve.
# * Chart 2 is jagged by design: each tract enters and leaves the window as a
# block of people. Raise window_pct (e.g. 15) for a smoother line.
# * Edge tracts: a tract whose interior point is inside the urban area counts
# in full, including any rural land it holds outside the boundary, so the
# low-density tail is slightly overstated. To check the boundary match, map
# it: plot(st_geometry(ua)); plot(st_geometry(tr[tr$GEOID %in% df$GEOID, ]), add = TRUE)
# * People per mi² = people per km² x 2.59.
# ---------------------------------------------------------------------------
Change in Mode Share
Changing mode share or diverting auto trips to transit isn't the point. Getting people where they need to go is. Measure total trips, compare automobile delay with and without the transit improvement. Compare the cost of transit with the cost of freeway improvements needed to achieve similar outcomes in the next 30 years. Past a certain point, the math says "build transit", and once you are there, the game is just optimizing land use near transit.
Wednesday, October 14, 2026
Access has economic value. Mobility has zero economic value.
"Mobility has inherent economic value and induced demand is made up of real people, making real choices they couldn't make before."
Access has economic value. Mobility has zero economic value. If I double both the speed and the distance of a trip, mobility has doubled, but access hasn't changed, and there has been zero economic benefit.
"That is just wrong. If people can travel longer distances at the same generalised cost, then they have more opportunities for where to live and work. This has value for them. That is an economic benefit".
Wednesday, October 7, 2026
Homeowner cartels and urban political economics
The hermeneutical principle is "The Purpose of a System is What it Does". The economic principle is that people respond to incentives.
A cartel is an association of suppliers with the purpose of maintaining prices at a high level. The only suppliers of land (they aren't making any more of it) are existing landowners, and for most municipal jurisdictions, that means the owner occupiers of single-family detached homes.
'Zoning is a conspiracy to protect property values'. Anyone who owns a house has a vested interest in preventing prices from decreasing, and from encouraging them to increase to a higher level. A mortgage locks in both the minimum acceptable level of price and the minimum rate of appreciation* (the mortgage rate).
Prices are the equilibrium between supply and demand, so maintaining prices means ensuring supply grows no faster than demand. Where supply is elastic, and new housing can be readily supplied, home prices don't appreciate. The less readily equivalent housing can be supplied; the less elastic supply is. Locations being unique and space being finite, new housing is less readily-equivalent to existing housing, simply because it is more distant. The more distant something is, the less equivalent it is--even equally accessible locations offer access to different things.
In a stagnant metropolitan region, housing supply matches housing demand and the price level is stable*. It is only in growing regions that the value of homes appreciates. It is the value of location that appreciates, not the decaying bundle of sticks and tar atop it. And the value of a location is proportional to the transportation costs of that location--central locations are always more valuable. Hence, assumptions of housing appreciation rely on the production of new housing in inferior locations.
So the homeowner cartel to maintain the value of their asset, they must maintain the value of their location but preventing additional supply. Conceptually, doubling the number of houses available halves the premium of the location, simply because that premium is divided over more houses. (Empirically, the relationship is non-linear, because doubling the houses halves the land per house and the premium for a location isn't a 1:1 relationship to the number of houses).
Municipal policies depend on voter preferences, and all voters must be residents. When a new suburban municipality incorporates, land values don't support apartments, so all housing (and hence all voters) are home-owner households, are part of the cartel. Voters elect people who restrict supply, restricted supply fuels appreciation, and the cartel works for its members.
The cartel can even permit some rental housing, as renters tend to be younger or poorer and hence less politically engaged, so there is minimal political conflict - the cabal remains hegemonic, and the only conflict is between developers (who want to build/sell apartments) and the homeowner cabal of voters, who don't want competitors for parking, views or public amenities. It's a very stable equilibrium, as the stock dominates and the financial incentives for density are low enough that developers stick to the low-hanging fruit.
But inevitably, as the value of a location appreciates, so do the rents to live in that location, for both apartments and single-family rentals. Which, when it happens faster than wage growth, displaces existing renters to worse housing (more remote/lower quality). Still an equilibrium, but a less stable one, as fewer voters are members of the cartel. Higher rents are also destabilizing, as the support the development of new 'luxury' apartments, occupied by older, higher-income renters more likely to be politically active, and indifferent to homeowner cartel concerns about appreciation but sensitive to displacement. Worse yet, there is a substantial latent constituency of voters (otherwise politically marginal) that can be organized -- renters threatened by displacement by rising rents.
To buy off this constituency, the classic homeowner cartel tactic is to offer rent control: buying off political support through subsidy. It has the added advantage of blunting the development of apartments by capping rent and hence expected return on investment. Converting existing rental housing into 'permanently affordable housing' aids and abets this, by preventing existing rentals from being refurbished into 'luxury apartments' which might house politically active renters.
When homeowners cry "Manhattanization", it's not just a matter of density--it's the danger of a renter dominated polity indifferent or hostile to the political hegemony that underlies the homeowner cabal and continued property appreciation.
Maintaining the political consensus to limit supply to guarantee appreciation requires restraining the political power of both renters and landlords. The first is achieved by ensuring renters are politically marginal: poor, young and characterized by short tenure. Over time, traditional rent control sabotages the second and third characteristics, as renters age in place. Contrast housing vouchers, available only to the needy, ensuring that renter residents are always poor (and hence politically disengaged). Zoning unpleasant locations (noxious, noisy, smelly or dangerous) such as near freeways or along arterial roadways supports rapid turnover.
Homeowners don't disparage developers by accident: developers aren't part of the cartel. The mortgage rate they can support is set by the appreciation in rents, rather than directly by the appreciation in location as it is for the homeowner cartel. A municipality dominated by a homeowner cartel will be hostile to developers, and active in using regulation to constrain with unreasonable requirements for parking, facade articulation, and public improvements, while also delaying them with conditional approvals and design review. The explicit goal is both to ensure cartel members aren't negatively affected by new development, but the outcome is to limit the supply of new housing.
The real threat to the homeowner cartel is when developers make common cause with politically engaged renters to advocate for additional rental housing at the state level. Developers can supply the money for political advocacy, while politically engaged renters can rally an even larger latent constituency. At a municipal level, only resident renters can vote, and so only those in danger of displacement are willing to organize. But at the extra-municipal level, that constituency consists not just those threatened by displacement but anyone who has ever been displaced by rising rents and is hence sympathetic to existing renters.
Another classic cartel tactic is to permit new development, but to try to ensure new entrants are aligned with the cartel by permitting only for-sale condos intended for owner-occupiers.
The aims of the homeowner cartel are to:
- * limit new supply
- * marginalize renters
- * ensure any new housing is owner occupied
The tactics of the homeowner cartel are to:
- * use regulation (zoning, lot sizes) to restrict supply
- * use zoning to limit apartments to noxious locations
- * use rent-control / affordability mandates to buy off renters and make new development unattractive
- * convert existing market rental housing into income restricted housing
- * prioritize apartments with politically marginal renters (young, poor, high-turnover)
- * permit condos, but not apartments, despite identical physical form
*Interesting corollary is that if a mortgage sets the required appreciation rate, and the appreciation rate is zero (ie, in a stagnant metropolitan area), it implies lower future values, and hence lower present values, in the same way in which appreciation at a rate greater than the mortgage rate implies higher future values, and hence higher present values. Hence, in any stagnant metropolitan areas, the higher the interest rate, the greater the rate of depreciation in local housing prices. The magnitude of the effect is proportional to the number of people with new mortgages - older mortgages were financed (or refinanced) at lower rates.
Friday, October 2, 2026
AI has broken the job market
I don't mean bad for job seekers. I mean functioning badly as a market--a mechanism of matching buyers and sellers, mediated via prices.
A combination of AI and Workday makes applying for jobs badly very easy, which generates a lot of marginal applicants that still require review by HR. But the sheer number of candidates overwhelm HR folks, who fall back on easy-but-bad heuristics to filter out people they don't expect to be suitable. Degree completion (testing grit and intelligence) is a reasonable heuristic for junior staff. But it is an increasingly bad one for more senior roles, where experience is far more important. But Workday doesn't provide an easy tool to filter for "20 years of experience in X". In my field, professional organization credentials/certifications play that role - EIT/PE and AICP/FAICP are reliable indicators of seniority and capability, and (crucially) allows folks in HR to filter using a single uniform term.
By many metrics, modern job markets do much better on an aggregation basis--bringing together a lot of buyers and sellers in one place. Which is overwhelming. Given too much to choose from, analysis paralysis and decision fatigue are taking over. Further, HR folks are (like teachers) suffering from AI, which has undermined their traditional evaluation mechanisms. In the past, word processors and spell check eliminated the ability to use typing ability and/or spelling as a filtering heuristic. AI has destroyed the utility of the traditional cover letter and resume--a decent quality version of either can be produced with almost no effort by the applicant.
Further, a bigger market makes 'product comparison' (who is actually good at what / who is good to work for) much more difficult [1]. And when information asymmetry prevails, a 'market for lemons' emerges: any hire might be a lemon, nobody wants to overpay, so salary offers are lowballs, so none of the 'peaches' are willing to switch jobs, reinforcing the dynamic [2]. And so the job market locks up, which is terrible for both buyers and sellers.
Price comparison for labor is always difficult - people are non-fungible, and the smaller the organization, the less standard the role they perform, and the more the functions they undertake resemble the job description [3]. So there is a huge push to commoditize labor competencies using things like certificates and boot camps. It is, at best, a partial solution--degradation of any credential is inevitable. For-profit diploma mills emerged in response to the value of a college degree as a credential [4]. And any bootcamps [5] that relies on customers through-put has every incentive to maximize volume [6] and customer satisfaction, regardless of how that undermines the credentials of past bootcamps. In turn, the need for HR folks to evaluate the quality of bootcamps and mini-credentials is itself exhausting.
The long-term solution for ghost-written resumes/cover letters has long been interviews, which can't be conducted at scale. So to proxy that, more and more applications have required mini-essays, but which are subject to the same 'grading problems' as resumes and cover letters - AI generates a volume of text that outstrips human capacity to evaluate, and so HR departments increasingly outsource cognitive labor at to AI [7]. AI is both fallible and a black box [8] so unknown errors abound. Further, evaluative AI capacity is poor - AI is trained, not educated. It is reliant on its training [10] and does not get better over time [11], regardless of how many resumes it reviews. So the AI HR has available to it is hardly better than keyword searches, with all the associated flaws.
There is also the issue of deception. A certain amount of exaggeration is expected on a resume, but in addition to the market for lemons, it can generate a Red Queen's Race--people lie more and more simply to be considered. Anecdotally, in New York, the norm for bar-service is to claim an order of magnitude great experience - months for weeks, weeks for days. And in the context of things like Workfront, clearing (arbitrary) thresholds for applications to be considered by a human, deceiving the AI filter becomes a necessity.
#----------------------------------------------------------------------------
[1] Remote work magnifies this. There may be 5-10 employers (half of which are marginal) within a reasonable commute of home. But remote work requires evaluating employers all over the world. (Which has engendered entirely new varieties of employment scams). Evaluation is difficult, and expensive in time and cognitive capacity.
[2] The first thing medieval guilds (and trade fairs) do is mandate standard quality. Not because they like quality (they'd sell a lemon if they could get away from it) but because suspicion of low-quality drives down prices for everyone.
[3] Efforts to replace truck drivers with AV have foundered thus - much of what truck drivers do is not driving trucks but rather acting as a 'resolution agent' for immediate logistical problems (breakdowns, missing cargo, etc).
[4] Elite institutions (i.e. Yale) resist degradation by being exclusive, following a quality-not-quantity approach to education. Even if they admit a moron, that moron will become an educated moron.
[5] Awkwardly, the push to raise college graduation rates did not help: Trying to provide 'good value for money' for the public by reducing the drop-out rate seems attractive, but faced with a need to increase a statistic, the easiest way to achieve it is to lower standards. "When a measure becomes a target, it ceases to be a good measure". (Goodharts law). Reduction in graduation rates are merely a lagging indicator of a failure during the admissions process - people unlikely to be successful were admitted for not good reasons: a reasoning error conflating a mediating variable (college education) between grit/IQ and success as a causal variable, a cognitive error recognized by politely ignored because it enabled Type A academic Deans to build empires.
[6] Likewise, the MBA approach of reducing costs by reducing quality until volumes suffer doesn't work. In a market, the presence of competitors denies unlimited degradation. For a monopsonic public provider of higher education, there is no limit to the degradation of value (as nutrition, art history and English majors can attest).
[7] In the future, most work consists of evaluating the quality of AI outputs and "prompt engineering". This will drive both an explosion in the demand for such services, but also dramatic increase in the cost of non-AI services (Baumol's cost disease). A hairdresser who can evaluate AI outputs chooses alternative, more lucrative employment, and the price for the remaining hairdressers rises to reflect the reduced competition.
[8] Asked to explain its reasoning, an AI makes up a post-hoc explanation that fits the facts and deludes itself into thinking that was always what it thought. A distinction that can only be ascertained by lawyerly questioning or the parent of a precocious child. But, like the child, AI is getting better at an uncertain rate, with both greater cognitive capacity, but also greater meta-textual awareness - AI is getting better at assessing the qualities of its lies and the person asking the question, and so is getting better at being more deceptive. Arguably, this recapitulates the development of human cognition, where humans then developed a counter-strategy: to punish deception with viciousness disproportionate to its immediate harmfulness.
[9] AI isn't educated, but trained, and is reliant on its training to make inferences. The quality of that training data is degrading exponentially, as an ever-larger volume of web-scrapable material consists of AI produced content, and ever more of the good stuff is secured behind paywalls and access denied by robots.txt. (Evidence that AI is semi-legally harvesting user documents has been a long-term suspicion and the recent Navier-Stokes scandal practically validates the suspicion).
[10] It is not by accident that Google is now buying books by the load and scanning them all - it's a source of training data web-scraping can't match. Even the most dated Windows 3.1 guide represents substantially better training data than web-scraped AI generated content, because it was written, edited, reviewed and published in a context where publication (printing, warehousing, distribution, sales) represented a substantial and risky investment.
[11] In the not-too-distant future, AI training data will include everything ever written before AI, and there will emerge and industry of people whose role is to provide AI with training data by reviewing the quality of its responses and suggesting improvements, based on expert judgment. AI owners are already trying to crowdsource this specific form of labor, which will in turn fall victim to the market-for-lemons problem in which they only people who respond are cranks or malicious.








