How to use this
NYC publishes 2,396 datasets and no way to ask across them. This is the layer in between. Below are two real questions worked end to end — every step is a link that runs, and every step says what it should return, so a broken one is visible rather than quiet.
In a hurry? Run the first step and follow the strip at the top of the page.
Which page answers which question
| Page | When you are thinking | What it does |
|---|---|---|
| Catalog | “What does the city publish about this?” | Search 2,396 datasets by keyword. The way in when you do not know what the thing is called. |
| Explore | “How many, broken down how?” | Compose a query without writing SoQL — dataset, measure, filters, and a join to a second dataset. |
| Ask the agent | “I can describe it but not compose it.” | A sentence in, a cited answer out, with every tool call it made shown as it happens. |
| Analytics | “What is the catalog itself like?” | The corpus as a subject: what gets published, how often it updates, where the quality sits. |
| Map | “What has coordinates?” | The 496 datasets carrying usable geography, drawn without downloading any of them. |
| Findings | “Has someone already worked this out?” | Conclusions the agent reached in research mode, each with the query that produced it. |
| Endpoints | “Can something else call this?” | A saved question at a stable URL. Versioned, and it carries its own provenance. |
Worked example 1
Is my neighbourhood getting noisier?
One dataset, end to end: find it, cut it down, change the grain, and hand the answer to someone else as a URL. Every question in this app is some version of these four moves.
- 1 / 4
Ask the whole city first
311 is one dataset of 2,396 and holds 21 million rows. Nothing is downloaded — Socrata counts them where they sit and sends back the totals.
You should get — 20 rows — the Limit box defaults to 20 and there are 194 complaint types, so raise it to see the tail. Illegal Parking leads with 315,101, then HEAT/HOT WATER at 202,506. A second or two warm; up to half a minute if you are the first to ask it today.
- 2 / 4
Cut it down to noise, and break it out by borough
A third filter row — complaint type contains Noise — and a different grouping. The filter values autocomplete from what the column actually holds, which is the whole point: a guessed spelling returns nothing and reads as a quiet neighbourhood rather than as a typo.
You should get — 6 rows. Brooklyn 101,655, Manhattan 89,724, Bronx 85,658 — and an Unspecified bucket of 12, which is a real group, not an error.
- 3 / 4
Change the grain to something neighbourhood-sized
Only the grouping changes. A borough holds two million people, so it answers a different question than a ZIP does — and gives a different winner.
You should get — The top 20 of 217 ZIPs. 10456 in the Bronx leads with 8,535 — a borough answer and a ZIP answer disagree, and both are correct.
- 4 / 4
Hand the answer over as a URL
A published endpoint is a saved question at a stable address. Versions are immutable, so an edit by its author cannot break whoever is calling it, and the SoQL that produced the answer travels with it.
You should get — JSON, 25 rows. Add /provenance to the same URL for the query behind it.
Worked example 2
Do 311 calls and arrests concentrate in the same districts?
Two datasets. This is the one place the app will stop you, because the wrong answer here does not look wrong — it looks like a very large number.
- 1 / 3
Pick the key before you pick the dataset
Which datasets can be compared depends entirely on what you are comparing them by, so the key comes first and the candidates are ranked against it. This one is a boundary Socrata itself matched both datasets against.
You should get — 71 rows — one per community district — in a second or two, under two notes saying this is an alignment and not a record match. All 71 come back even though the Limit box says 20, because each side was aggregated first. The date filter applies to 311 only; the arrests column is that district's whole file.
- 2 / 3
Why that was a comparison and not a join
A community district is an area, not a thing. Matching rows on it pairs every 311 call in a district with every arrest in it — measured, that returned 9,448,296 rows for a single district whose true total was 60,124, and took 41 seconds to be wrong. So the composer aggregates each side separately and lines the two up. It picks this the moment the key is a place rather than an object, and there is no way to override it.
- 3 / 3
And when records really do correspond
A tax lot is a thing. Evictions and PLUTO both carry one, so a row here really does match a row there and a true join is right. Read the notes above the table — they say what the count is actually counting.
You should get — 6 rows under four notes. The counts are of matched pairs, not of evictions — one tax lot can carry many PLUTO rows, and the last note says so rather than letting you read 136,298 as evictions in the Bronx.
Another way in
Or ask for it in a sentence
The same engine, reached by describing what you want instead of composing it. Slower and less precise, and much better when you do not know what the dataset is called.
- 1 / 2
Ask the agent
It searches the catalog, reads the schema, and runs the query through the same planner the composer uses — so it gets the same refusals rather than writing SoQL that is accepted and wrong. Watch the activity list: every figure it reports came from one of those calls.
You should get — A tool call per line, then an answer citing the dataset by name and id. Twenty to sixty seconds — it is doing real work.
- 2 / 2
Take its answer back into the composer
An answer you can edit is worth more than one you can only read. Where the agent ran a query through the engine, its answer carries a link that opens that query in the composer, filled in and yours to change.
Vocabulary
- Pushdown
- The query runs on Socrata's servers, not here. A 21-million-row dataset scoped to one neighbourhood comes back as a few hundred bytes, which is why nothing is copied and no number is stale.
- Alias
- A readable name for a Socrata four-by-four id. erm2-nwe9 is 311. The id still works everywhere the alias does.
- Join
- Both datasets carry the same real-world identifier — a tax lot, a building, a restaurant — so a row here corresponds to a row there.
- Align
- The shared key is a place, not a thing. Each side is aggregated separately and the two are lined up. A comparison by area, not a record match.
- Endpoint
- A published query at /e/{slug}. Immutable versions, so an author's edit cannot break a caller.
- Provenance
- The query behind a number. Every endpoint and every finding carries it, so any figure can be re-derived instead of trusted.
- Quality grade
- Five measured dimensions over a sampled slice, computed here. It says how usable a dataset looks — not whether the city considers it authoritative.
- Finding
- A conclusion the agent saved in research mode, stored with its query and its rows so the next person gets it for free and can check it.
What this does, and what it does not do
Nothing is copied — The portal already filters and aggregates server-side — a 21-million-row dataset scoped to one neighbourhood returns a few hundred bytes in about two seconds. So this layer stores facts about datasets, never their rows, and pushes every query back to Socrata. That is why the numbers are never stale.
Joining and comparing are different — Matching on a tax lot means the same building, and a row join is right. Matching on a borough means the same area — joining on it multiplies rows instead of pairing them. The composer picks the right one from the key and tells you which it used.
A filter applies to the dataset you started from — In a comparison, the filter rows narrow the left-hand dataset. The other side is aggregated whole. That is stated in the result notes, and it is worth reading before comparing a month of one thing against a year of another.
Quality grades are computed here, not official — Five measured dimensions over a sampled slice. A grade says how usable a dataset looks, not whether the city considers it authoritative.
Where the data comes from — NYC Open Data, live. This project is not affiliated with the City of New York, and every answer links back to the dataset it came from.
Build on it
Everything the site uses is a public, unauthenticated endpoint — the same ones these pages call.
# a published question, and the query behind it
curl "https://data-citizen.vercel.app/e/nyc-311-by-borough?since=2026-06-01"
curl "https://data-citizen.vercel.app/e/nyc-311-by-borough/provenance"
# which datasets mention a term, across 56k column descriptions
curl "https://data-citizen.vercel.app/api/columns?q=zip&limit=5"
# what this dataset can be joined to, and how
curl "https://data-citizen.vercel.app/api/joins/candidates?dataset=erm2-nwe9&column=bbl"
# compose a query yourself
curl -X POST "https://data-citizen.vercel.app/api/query" \
-H 'content-type: application/json' \
-d '{"query":{"dataset":"erm2-nwe9","groupBy":["borough"],
"aggregations":[{"op":"count","as":"total"}]}}'