<!-- orbyt:chrome:begin -->
---
title: "Eighty one cities, ranked by a number that never knew which city it was"
description: "A leaderboard ranked eighty one metros by wage growth. The rate came from the letters in a role slug, and the city cancelled out of the arithmetic."
author: "Justin Bartak"
published: 2026-10-28
modified: 2026-10-28
canonical: https://www.orbytlabs.ai/blog/building-wage-growth
---
<!-- orbyt:chrome:end -->

# Eighty one cities, ranked by a number that never knew which city it was

Sum the letters in "data-scientist." Do a little arithmetic on the total. Print the answer as a wage growth rate.

That was the engine under a leaderboard. All eighty one metros in the catalogue, ranked best to worst by projected year over year growth. A top twenty table, a bottom ten, and two more rankings for AI roles and tech roles.

It had the shape of a page that knows something. Twenty rows, ordered, a number in the right hand column.

I deleted it on August 6 and left an explanation at the [same URL](https://www.orbytlabs.ai/orbyt-jobs/salaries/wage-growth). Here is what was actually wrong with it, and what sits on the salary pages now instead.

## How does a city ranking work with no city in it?

It does not. That is the part I only understood this week, when I went back and replayed the deleted function.

The rate came out of `getSalaryTrends`. That function built a five year wage history. It divided today's estimate by a growth rate, then divided again, and again. The rate itself was a flat base plus a bonus. Three points for most roles, six for anything the function counted as an AI role, which it decided from a short list of slug prefixes. The bonus came from the sum of the character codes in the slug. `getYoYGrowth` read the last two points of that history and returned the difference.

Watch what the division does. Last year is defined as this year divided by the rate. The city's wage level sits in the numerator and in the denominator.

It cancels.

So the rate for a given role is identical in all eighty one metros. I checked rather than assumed. Replay the function against today's catalogue and software-engineer comes back with the same figure in every city.

The page averaged a dozen roles per metro and sorted the result. What separated the top of that leaderboard from the bottom was rounding to the nearest thousand dollars. That is the whole ranking.

## What kept the old page alive?

The page did label those figures modelled. That adjective is why it survived. Every time I read the page I read the word, agreed with it, and moved on to the next thing.

What I was not reading was the shape of the claim. A projection says here is a guess. A ranking says somebody established an order. Those are different promises and the second one is much larger.

I ranked eighty one metros using numbers that carried no wage evidence at all. The word above the table did not explain where they came from, and I never asked it to.

**A ranking is a stronger claim than a projection, and the label above the table does not undo it.**

So the functions went. `getSalaryTrends`, `getYoYGrowth` and the `SalaryTrend` type left the data module and its edge twin the same day. The API fields carrying them outward went too.

## What can a reader check on a salary page now?

Open a role in a city. There are two numbers on it, and they do not pretend to be one number.

The top figure is ours. It is an estimate: a role baseline moved by the local cost of living. A small deterministic variance keeps it off the exact product of those two. Nothing looks up a percentile while the page renders.

Under it sits a second block. Its heading is "Government benchmark for this occupation". It carries the published figure from the federal occupational wage survey, for the occupation code this role maps to in this metro area. It also carries the count of employed workers behind that figure, and a link to the government page.

The occupation code is printed on purpose. The survey publishes per occupation, not per job title. So the block names its occupation. It does not let a reader assume the figure is about their exact title.

Nothing averages the two into one confident number. You get ours, you get theirs, and you get the distance between them. The gap is the useful part.

On a role page with no city, that block prints two published national medians side by side. One from the May 2025 release, one from May 2024.

No arrow. No change. No rate between them.

## Why is there nothing between those two releases?

Because the agency that published them says not to compute one. Its sentence ships on our page, directly under the figures. The Bureau of Labor Statistics does not encourage the use of [OEWS data](https://www.bls.gov/oes/) for time series analysis.

Differences between two releases carry occupational and geographic reclassification. They carry methodology changes and a three year pooled sample design. Real movement in pay is in there somewhere too. Subtracting the two is one line of code. I have thought about writing it more than once.

I take the publisher at their word about their own survey. A wrong number in that slot is one I would have to defend to somebody sitting in a salary negotiation holding it.

## Which broken column did I leave standing?

The [companies hub](https://www.orbytlabs.ai/orbyt-jobs/salaries/companies) carries a column headed "vs Q2 2026" beside each employer's estimated median for AI engineer.

It looks like growth. It is not.

The column reads from a committed snapshot, `public/data/salary-previous-quarter.json`. It was generated on June 20. It holds one median for each of the 3,445 roles in the catalogue. So it measures the distance between today's estimate and a June copy of the same estimate. Bump a baseline in July and the column reports my own revision as movement in the market.

It gets worse. I checked this one rather than assuming it. The script that regenerates the snapshot, `scripts/snapshot-salary-data.cjs`, resolves its input through a constant named `DATA_FILE` that points at `app/(site)/salaries/salary-data.ts`. The salary modules moved under the jobs section. The constant did not, and that path does not exist in the repo as it stands.

I have not run the script. So what I can tell you is what the constant points at, not what the failure looks like at runtime. Either way the snapshot has not moved since June.

**I shipped that column, I have opened that page many times since, and I did not see it.** It renders a plausible number no matter what, and a plausible number never asks to be checked.

The column is still on the page as I write this. I am deleting it rather than relabelling it. "Change since our June estimate" would be an accurate label. It would also be a label on a figure no reader has any use for. That deletion has not shipped. Until it does, the paragraph above is the honest caption for that column.

## So when does the table come back?

Not when a second release lands. An earlier version of this page promised exactly that. It was wrong for the reason two sections up. A second release gives me a second number. Comparability is a different question, and the publisher has already answered it.

What a real table needs is narrower than ingestion. Rows where the occupation definition and the metro boundary can be shown not to have changed between the two releases. A calculation that still reads honestly when it is printed beside the caveat. And the caveat printed beside the number rather than filed in a footnote.

The nearby [projections page](https://www.orbytlabs.ai/orbyt-jobs/salaries/projections) is the pattern I would follow. It used to hand every role its own convincing growth rate, out of the same slug arithmetic. Now it applies two stated rates, one for AI roles and one for everything else. It says so in the open. Every cell can be reproduced with a calculator.

Wage growth by metro is a real search. People type it. I expected that to cost traffic. A table catches searches that prose does not, and I gave up the table. I have not measured what it cost.

I kept the URL because the question deserves a real answer even when the answer is not yet.

One implementation detail follows from that, and it is deliberate. The page allows an unlimited snippet length, so an answer engine can lift the whole explanation rather than a clip of it. The sentence that makes this page honest is a negation. A negation is the easiest thing in a paragraph for a clip to lose.

## What is a salary band, and why does that belong here?

Because the same discipline runs one layer down, into the estimates themselves.

A band is the low, median and high triple that a role's page serves. Run `npm run check:salary-band-collisions` against the catalogue. It prints 513 distinct bands across the 3,445 roles. Of those roles, 3,199 share a band with at least one other. The largest single band covers 394 titles, software-engineer and frontend-developer and backend-developer among them.

The answer to that is disclosure, not invention. Differentiating those titles means making up numbers for most of them, which is the thing this entire page exists to not do.

The [full methodology](https://www.orbytlabs.ai/orbyt-intelligence/methodology) has the rest of it, and the [city comparisons](https://www.orbytlabs.ai/orbyt-jobs/salaries/cities) put estimates and published benchmarks next to each other across metros.

Related reading:

- [The salary pages](https://www.orbytlabs.ai/orbyt-jobs/salaries) where our estimate and the government benchmark sit on one screen with the gap between them left visible
- [Salary projections](https://www.orbytlabs.ai/orbyt-jobs/salaries/projections) two stated rates, no per role arithmetic, every cell reproducible with a calculator
- [The companies hub](https://www.orbytlabs.ai/orbyt-jobs/salaries/companies) including the column this piece calls broken, which is still there as you read it
- [More build notes](https://www.orbytlabs.ai/blog) the rest of the series, including the entries where shipping meant deleting

<!-- orbyt:chrome:begin -->
This is the markdown version of https://www.orbytlabs.ai/blog/building-wage-growth. The page there is the canonical copy.
<!-- orbyt:chrome:end -->
