How to pull Cook County, Illinois parcel, assessment, tax-rate, and equalization data directly from the public Socrata API — without scraping, without reCAPTCHA, without auth.
Cook County publishes its property, assessment, and tax-rate data on a Socrata-powered Open Data portal at datacatalog.cookcountyil.gov. Everything you need lives there as JSON/CSV with a documented query language. No authentication is required to read. A free app token raises the throttle ceiling but is not strictly required.
cookcountytreasurer.com, cookcountypropertyinfo.com) is a per-PIN lookup portal. Do not bulk-scrape it. Use it only for live billing/payment status that's not in open data.The instinct is to point Playwright at cookcountyassessoril.gov/address-search and call it a day. Don't. Here's what you'd actually be fighting:
Cook County publishes the underlying tables directly. You skip every anti-bot layer by going to the same source the public-facing site is pulling from.
| Surface | What it is | Use for |
|---|---|---|
Cook County Open Data Portaldatacatalog.cookcountyil.gov |
Socrata-hosted catalog of every Cook County dataset. Free JSON/CSV API. No auth for read. | Everything: parcels, assessments, sales, exemptions, tax rates, agency rates, geographic boundaries. |
CCAO Data GitHubgithub.com/ccao-data |
Cook County Assessor's Office Data Science team. Open-source R/Python packages + the ptaxsim tax-bill-simulation SQLite database. |
Bulk historical analysis without API pagination. Tax-bill modeling. |
| cookcountypropertyinfo.com | Treasurer's per-PIN portal. Static HTML detail pages keyed by 14-digit PIN. | Live billing/payment status (paid/unpaid, due dates) — the one thing not in open data. PIN-by-PIN only. |
| cookcountyassessor.com/pin/<PIN> | Assessor's PIN detail page. Static enough to parse. | Fallback if a specific rendered field isn't in the API. Rarely needed. |
| IDOR annual order | Illinois Department of Revenue's annual state multiplier announcement. | The single State Equalization Factor per tax year. |
Every Cook County dataset has a stable 4x4 resource ID (e.g. uzyt-m557) and is queryable at:
https://datacatalog.cookcountyil.gov/resource/{resource-id}.json
https://datacatalog.cookcountyil.gov/resource/{resource-id}.csv
You filter using SoQL (Socrata Query Language) via query-string parameters prefixed with $:
| Parameter | Purpose | Example |
|---|---|---|
$where | SQL-like filter | $where=pin='32213210090000' |
$select | Pick columns | $select=pin,certified_tot,year |
$order | Sort | $order=year DESC |
$limit | Page size (max 50,000) | $limit=50000 |
$offset | Pagination cursor | $offset=100000 |
$q | Free-text search across columns | $q=123 N MAIN |
Register at datacatalog.cookcountyil.gov/profile/edit/developer_settings and pass X-App-Token as a header. Without a token you share an anonymous throttling pool; with one you get your own much-higher ceiling. Either way, reads are free.
| Dataset | Resource ID | Key columns | Use it for |
|---|---|---|---|
| Parcel Addresses | 3723-97qp | pin, prop_address_full, city, zipcode | Address → PIN lookup |
| Parcel Universe | nj4t-kc8j | pin, year, class, township_code, nbhd_code, tax_code | Master list of every parcel |
| Parcel Universe (current year) | pabr-t5kh | Same, current year only | Faster queries for "now" |
| Assessed Values | uzyt-m557 | pin, year, mailed_tot, certified_tot, board_tot | Assessment history by stage (mailed / certified / Board of Review) |
| Parcel Sales | wvhk-k5uv | pin, sale_date, sale_price, deed_type | Sales transaction history |
| Single/Multi-Family Improvements | x54s-btds | pin, bldg_sf, year_built, rooms, beds, bsmt | Building characteristics |
| Residential Condo Unit Characteristics | 3r7i-mrz4 | Condo-specific | Condo details |
| Appeals | y282-6ig3 | pin, appeal_id, result, reason | Appeal history |
| Permits | 6yjf-dfxs | pin, permit_date, permit_type | Building permits |
| Property Tax-Exempt Parcels | vgzx-68gb | Exempt-status parcels | Filter out exempt properties |
| Neighborhood Boundaries (GeoJSON) | pcdw-pxtg | Geographic polygons | Mapping |
| Commercial Valuation | csik-bsws | Commercial-only | Income/cap-rate analysis |
GET https://datacatalog.cookcountyil.gov/resource/3723-97qp.json
?$where=upper(prop_address_full) like upper('%123 N MAIN%')
&$limit=10
GET https://datacatalog.cookcountyil.gov/resource/uzyt-m557.json
?pin=32213210090000
&$order=year DESC
The Treasurer collects the bill; the County Clerk publishes the tax rates and agency composition. All of it is on the same Open Data portal.
| Dataset | Resource ID | Shape | Use it for |
|---|---|---|---|
| Tax codes, agencies, and rates | 9sqg-vznj | Long format — one row per (year, tax_code, agency) | ★ Best for analytics — pivot/join/aggregate freely |
| Tax code Rates | 8vrk-e2ck | Wide format — one row per tax_code, columns per year | Quick year-over-year rate comparison |
| Tax Agency Rates | 9sgn-ibhu | Wide format — per agency, by year | "How much of the bill goes to the school district?" |
| Residential Exemption Amounts (archived) | a536-gjbq | Per tax_code & year, includes equalization_factor | Historical equalization factor at tax-code granularity (2013-pre-2022) |
GET https://datacatalog.cookcountyil.gov/resource/9sqg-vznj.json
?tax_year=2023
&tax_code=10001
Returns one row per taxing body (county, schools, parks, library, etc.) plus the composite tax_code_rate. Sum the agency_rate values across the rows and you'll match the composite — that's the breakdown that shows up on a Cook County bill.
Illinois law requires property to be assessed at 33⅓% of fair market value statewide. Cook County, by ordinance, assesses at much lower fractions (10% residential, 25% commercial). The State Equalization Factor is the multiplier the Illinois Department of Revenue applies each year to bring Cook County's aggregate assessed value up to the statewide 33⅓% standard. It's how the state ensures Cook County pays its fair share toward state-funded programs.
It is exactly one number per tax year, applied county-wide. Recent values hover around 3.0.
The formula every Cook County bill follows:
EAV = AssessedValue × StateEqualizationFactor
NetEAV = EAV − Exemptions
Bill = NetEAV × (TaxCodeRate / 100)
Where to get the factor:
a536-gjbq dataset includes equalization_factor per tax code per year (2013-ish through ~2021).For programmatic use, the cleanest approach is a tiny static lookup table you maintain once a year:
# equalization_factors.json — update once per year
{
"2019": 2.9160,
"2020": 3.2234,
"2021": 3.0027,
"2022": 2.9237,
"2023": 3.0163,
"2024": 3.0103
// Values shown are illustrative. Confirm against the IDOR order for production use.
}
Putting the joins in order, end to end:
# 1. Resolve address to PIN
3723-97qp : address (free text) → pin
# 2. Pull the parcel's tax code & class
nj4t-kc8j : pin + year → tax_code, class, township
# 3. Pull the assessed value (latest stage available)
uzyt-m557 : pin + year → certified_tot (or board_tot)
# 4. Pull the tax code rate & agency breakdown
9sqg-vznj : tax_code + year → tax_code_rate, [agency, agency_rate]
# 5. Apply the state equalization factor (static lookup)
equalization_factors[year] → state_eq_factor
# 6. (Optional) Subtract any exemptions
vgzx-68gb / exemption tables → exemption_total
# 7. Compute
EAV = certified_tot * state_eq_factor
NetEAV = EAV - exemption_total
Bill = NetEAV * (tax_code_rate / 100)
That's the entire bill. The Treasurer's website does the same math; you're just doing it from raw inputs.
Drop this in a file and run. No dependencies beyond requests.
import requests, urllib.parse
BASE = "https://datacatalog.cookcountyil.gov/resource"
HEADERS = {"X-App-Token": ""} # optional, leave blank or add your token
def soda(resource, **params):
url = f"{BASE}/{resource}.json"
return requests.get(url, params=params, headers=HEADERS, timeout=15).json()
def find_pin_by_address(query):
where = f"upper(prop_address_full) like upper('%{query}%')"
return soda("3723-97qp", **{"$where": where, "$limit": 10})
def parcel_universe(pin, year=None):
params = {"pin": pin}
if year: params["year"] = year
return soda("nj4t-kc8j", **params)
def assessed_values(pin):
return soda("uzyt-m557", pin=pin, **{"$order": "year DESC"})
def tax_rate_composition(tax_code, year):
return soda("9sqg-vznj", tax_code=tax_code, tax_year=str(year))
def estimate_bill(pin, year):
parcel = parcel_universe(pin, year)[0]
av = float(assessed_values(pin)[0]["certified_tot"])
rate = float(tax_rate_composition(parcel["tax_code"], year)[0]["tax_code_rate"])
eq = EQUALIZATION_FACTORS[str(year)] # static lookup
eav = av * eq
bill = eav * (rate / 100)
return {"pin": pin, "year": year, "av": av, "eq": eq,
"eav": eav, "rate_pct": rate, "est_bill": round(bill, 2)}
EQUALIZATION_FACTORS = {"2023": 3.0163, "2024": 3.0103} # maintain yearly
if __name__ == "__main__":
print(estimate_bill("32213210090000", 2023))
Browser or Node — works either way. Useful for a thin client-side property-lookup form.
const BASE = "https://datacatalog.cookcountyil.gov/resource";
async function soda(resource, params = {}) {
const qs = new URLSearchParams(params).toString();
const r = await fetch(`${BASE}/${resource}.json?${qs}`);
return r.json();
}
async function findPinByAddress(q) {
return soda("3723-97qp", {
"$where": `upper(prop_address_full) like upper('%${q}%')`,
"$limit": 10
});
}
async function taxComposition(taxCode, year) {
return soda("9sqg-vznj", { tax_code: taxCode, tax_year: String(year) });
}
findPinByAddress("123 N MAIN").then(console.log);
$limit max is 50,000 rows per request. Use $offset to walk a large set.mailed, certified (after assessor finalizes), and board (after Board of Review). For tax-bill math use certified_tot or board_tot, not mailed_tot.class field is a 3-digit code (202 = single-family, 299 = condo, etc.). Look up the full classification table when you need to filter by property type.One legitimate scraping scenario remains: live billing/payment status. This is not published on open data because it changes daily. If you need to know whether a specific PIN is paid or delinquent right now:
https://www.cookcountypropertyinfo.com/pin/<14-digit-pin>
The detail page renders server-side and the relevant fields (Total Amount Billed, Amount Paid, Last Payment Date, Status) are stable selectors. There is no reCAPTCHA on the GET path — only on POST forms. Polite scraping rules apply:
Kaizen-CCAO-Bot/1.0 (contact@kaizenailab.com))If you ever need to programmatically submit the Assessor's address-search form (you probably don't — the API does this better), you'd need to handle reCAPTCHA via 2Captcha or similar. Cost is ~$2 per 1,000 solves. Not worth it; use the API.
| What | Resource | JSON URL |
|---|---|---|
| Parcel Addresses | 3723-97qp | /resource/3723-97qp.json |
| Parcel Universe | nj4t-kc8j | /resource/nj4t-kc8j.json |
| Parcel Universe (current) | pabr-t5kh | /resource/pabr-t5kh.json |
| Assessed Values | uzyt-m557 | /resource/uzyt-m557.json |
| Parcel Sales | wvhk-k5uv | /resource/wvhk-k5uv.json |
| Improvement Characteristics | x54s-btds | /resource/x54s-btds.json |
| Condo Characteristics | 3r7i-mrz4 | /resource/3r7i-mrz4.json |
| Appeals | y282-6ig3 | /resource/y282-6ig3.json |
| Permits | 6yjf-dfxs | /resource/6yjf-dfxs.json |
| Tax-Exempt Parcels | vgzx-68gb | /resource/vgzx-68gb.json |
| Commercial Valuation | csik-bsws | /resource/csik-bsws.json |
| Neighborhood Boundaries | pcdw-pxtg | /resource/pcdw-pxtg.json |
| Tax codes, agencies, rates | 9sqg-vznj | /resource/9sqg-vznj.json |
| Tax code Rates (wide) | 8vrk-e2ck | /resource/8vrk-e2ck.json |
| Tax Agency Rates (wide) | 9sgn-ibhu | /resource/9sgn-ibhu.json |
| Residential Exemption Amounts (archived; has eq. factor) | a536-gjbq | /resource/a536-gjbq.json |
Wrap these endpoints in a small Python or TypeScript package keyed to the estimate_bill() flow in §8, and you have a complete address-to-bill engine without touching a single scraper. Maintain the State Equalization Factor lookup once per year and you're done.