← back to La Permits School
Add pull_permits_live.py: LIVE current LA permits (feed pi9x-tg5x, ~daily) with assessor+LADBS enrichment URLs; document live-vs-named tradeoff
4485919f6dae9592ef32756b9831ecf45a6cd679 · 2026-08-10 14:32:55 -0700 · Steve Abrams
Files touched
M FINDINGS.mdM README.mdA pull_permits_live.py
Diff
commit 4485919f6dae9592ef32756b9831ecf45a6cd679
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 10 14:32:55 2026 -0700
Add pull_permits_live.py: LIVE current LA permits (feed pi9x-tg5x, ~daily) with assessor+LADBS enrichment URLs; document live-vs-named tradeoff
---
FINDINGS.md | 12 +++++
README.md | 16 ++++++
pull_permits_live.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 178 insertions(+)
diff --git a/FINDINGS.md b/FINDINGS.md
index 55654b2..97fd914 100644
--- a/FINDINGS.md
+++ b/FINDINGS.md
@@ -53,6 +53,18 @@ account for ~$7.56B (≈21%)** of all $36.1B.
Note the two business models visible in the numbers: **Hensel Phelps = few, huge jobs**
(10 permits, $161M avg) vs **Swinerton = many, smaller jobs** (23 permits, $30M avg).
+## Data availability — live vs. named (important)
+The City of LA **stopped publishing contractor/applicant/principal NAMES in its open
+permit feed after 2023-05-19** (a privacy/policy change). As of Aug 2026:
+- The **only openly-updated feed** (`pi9x-tg5x`, refreshed to within ~2 days) carries
+ **valuation / type / address / APN / council district / status but NO names.**
+- Every **name-bearing** dataset (`d9aa-v8bm`, `hbkd-qubn`, `jqz4-22tu`) is **frozen at
+ 2023-05-19**; the previously-open `nbyu-2ha9` is now login-gated.
+
+So this project ships two pullers: `pull_permits.py` (named, 2013–2023 snapshot) and
+`pull_permits_live.py` (current, nameless — enrich the owner/developer live via the LA
+County Assessor by APN). No free source is both live AND contractor-named.
+
## Method & honesty notes
- **Source:** public City of LA open data (LADBS `d9aa-v8bm`) — a stable 2013–2023 snapshot.
No paid databases used. Contractor entity enrichment (CA SOS / CSLB) is a guided lookup, not
diff --git a/README.md b/README.md
index a741f96..eb17b9b 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,22 @@ public LA Open Data Portal and writes a clean CSV — including **contractor,
applicant, and principal names**, project **valuation**, address, **APN**, and a
ready-made **California Secretary of State** lookup URL per contractor.
+## ⚡ LIVE vs. historical (read this first)
+The City **stopped publishing contractor/applicant NAMES in the open feed after
+2023-05-19** (privacy/policy change). So you pick one:
+
+- **Want CURRENT permits (updated ~daily)?** → `pull_permits_live.py` (feed `pi9x-tg5x`,
+ refreshed to within ~2 days). Has valuation / type / address / **APN** / council
+ district / status — but **no contractor names**. Enrich the **owner/developer** live
+ via the LA County Assessor (by APN) and the contractor via an LADBS permit lookup.
+ ```bash
+ python3 pull_permits_live.py --min-valuation 5000000 --days 120 # -> permits_live.csv
+ ```
+- **Want contractor/applicant/principal NAMES?** → `pull_permits.py` (feed `d9aa-v8bm`),
+ but that data is a **frozen 2013–2023 snapshot** — the last free named permit data.
+
+There is no free source that is BOTH live AND contractor-named; that ended mid-2023.
+
Two free public data sources, no signup, no cost:
| Source | What it gives | Access |
diff --git a/pull_permits_live.py b/pull_permits_live.py
new file mode 100644
index 0000000..0f01c3f
--- /dev/null
+++ b/pull_permits_live.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""
+pull_permits_live.py — LIVE, up-to-date LA building permits (updated ~daily). $0, stdlib only.
+
+Uses the City's CURRENT open feed `pi9x-tg5x` (refreshed to within ~2 days of today),
+filtered to significant commercial/new construction. Writes a clean CSV with, per permit,
+a live LA County Assessor OWNER lookup (by APN) and an LADBS permit lookup (by permit #).
+
+IMPORTANT — why this differs from pull_permits.py:
+ The City STOPPED publishing contractor/applicant NAMES in the open feed after
+ 2023-05-19 (privacy/policy change). The only openly-updated feed (`pi9x-tg5x`) is
+ LIVE but carries NO name fields. So for current data the party you enrich live is
+ the OWNER/DEVELOPER (via the county Assessor by APN) — which for a loan officer is
+ the more useful party anyway. The GC name, if needed, is a per-permit LADBS lookup.
+
+Usage:
+ python3 pull_permits_live.py # commercial >$1M, last 365d -> permits_live.csv
+ python3 pull_permits_live.py --min-valuation 5000000 --days 90
+ python3 pull_permits_live.py --permit-types "Bldg-New" --out new_live.csv
+"""
+
+import argparse
+import csv
+import datetime as _dt # only for arg parsing of --days offset input by the user
+import sys
+import time
+import urllib.parse
+import urllib.request
+
+DATASET = "pi9x-tg5x"
+BASE = "https://data.lacity.org/resource/{}.json".format(DATASET)
+PAGE = 50000
+# LA County Assessor parcel detail (current owner) by 10-digit AIN (= apn, no dashes):
+ASSESSOR = "https://portal.assessor.lacounty.gov/parceldetail/"
+# LADBS Online Building Records search (find the permit -> contractor):
+LADBS = "https://www.ladbsservices2.lacity.org/OnlineServices/OnlineServices/OnlineBuildingRecords"
+
+OUT_FIELDS = [
+ "permit_nbr", "issue_date", "status_desc", "permit_type", "permit_sub_type",
+ "use_desc", "valuation", "square_footage",
+ "address", "zip_code", "council_district", "apn",
+ "assessor_owner_url", "ladbs_permit_url",
+]
+
+
+def build_where(min_valuation, permit_types, since):
+ clauses = ["valuation IS NOT NULL"]
+ if min_valuation:
+ # valuation is stored as TEXT on the live feed -> cast to number to compare
+ clauses.append(f"(valuation::number) > {int(min_valuation)}")
+ if permit_types:
+ quoted = ",".join("'" + t.replace("'", "''") + "'" for t in permit_types)
+ clauses.append(f"permit_type in({quoted})")
+ if since:
+ clauses.append(f"issue_date >= '{since}T00:00:00'")
+ return " and ".join(clauses)
+
+
+def fetch_page(where, offset, app_token=None):
+ import json
+ params = {"$where": where, "$order": "(valuation::number) DESC", "$limit": PAGE, "$offset": offset}
+ if app_token:
+ params["$$app_token"] = app_token
+ url = BASE + "?" + urllib.parse.urlencode(params)
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
+ for attempt in range(4):
+ try:
+ with urllib.request.urlopen(req, timeout=60) as r:
+ data = json.load(r)
+ if isinstance(data, dict): # Socrata error object
+ print(" ! API error:", data.get("message", data), file=sys.stderr)
+ return []
+ return data
+ except Exception as e:
+ if attempt == 3:
+ print(f" ! giving up on offset {offset}: {e}", file=sys.stderr)
+ return []
+ time.sleep(2 * (attempt + 1))
+ return []
+
+
+def shape(row):
+ apn = (row.get("apn") or "").strip()
+ return {
+ "permit_nbr": row.get("permit_nbr", ""),
+ "issue_date": (row.get("issue_date") or "")[:10],
+ "status_desc": row.get("status_desc", ""),
+ "permit_type": row.get("permit_type", ""),
+ "permit_sub_type": row.get("permit_sub_type", ""),
+ "use_desc": row.get("use_desc", ""),
+ "valuation": row.get("valuation", ""),
+ "square_footage": row.get("square_footage", ""),
+ "address": row.get("primary_address", ""),
+ "zip_code": row.get("zip_code", ""),
+ "council_district": row.get("cd", ""),
+ "apn": apn,
+ "assessor_owner_url": (ASSESSOR + apn) if apn else "",
+ "ladbs_permit_url": LADBS,
+ }
+
+
+def main():
+ ap = argparse.ArgumentParser(description="LIVE LA permits from the current open feed.")
+ ap.add_argument("--min-valuation", type=int, default=1_000_000, help="min $ (default 1000000; 0 = off)")
+ ap.add_argument("--permit-types", default="Bldg-New,Bldg-Addition",
+ help="comma list; '' = all. e.g. Bldg-New, Bldg-Addition, Bldg-Alter/Repair")
+ ap.add_argument("--days", type=int, default=365, help="only permits issued in the last N days (0 = all)")
+ ap.add_argument("--since", default="", help="explicit YYYY-MM-DD floor (overrides --days)")
+ ap.add_argument("--limit", type=int, default=0, help="cap rows (0 = all)")
+ ap.add_argument("--out", default="permits_live.csv")
+ ap.add_argument("--app-token", default="")
+ args = ap.parse_args()
+
+ since = args.since
+ if not since and args.days:
+ since = (_dt.date.today() - _dt.timedelta(days=args.days)).isoformat()
+
+ permit_types = [t.strip() for t in args.permit_types.split(",") if t.strip()]
+ where = build_where(args.min_valuation if args.min_valuation > 0 else 0, permit_types, since)
+
+ print("LIVE LADBS permit puller — cost: $0 (current LA Open Data feed pi9x-tg5x)")
+ print(f" filter : {where}")
+ print(f" output : {args.out}")
+
+ written, offset = 0, 0
+ with open(args.out, "w", newline="", encoding="utf-8") as f:
+ w = csv.DictWriter(f, fieldnames=OUT_FIELDS)
+ w.writeheader()
+ while True:
+ rows = fetch_page(where, offset, args.app_token or None)
+ if not rows:
+ break
+ for row in rows:
+ w.writerow(shape(row))
+ written += 1
+ if args.limit and written >= args.limit:
+ break
+ print(f" ... {written} rows", flush=True)
+ if (args.limit and written >= args.limit) or len(rows) < PAGE:
+ break
+ offset += PAGE
+ time.sleep(0.3)
+
+ print(f"\nDone. {written} LIVE permits -> {args.out}")
+ print("Enrich (free, live): open assessor_owner_url for the CURRENT owner/developer by APN;")
+ print("open ladbs_permit_url and search the permit # for the contractor of record.")
+
+
+if __name__ == "__main__":
+ main()
← 7bbff9c Add make_chart.py: stdlib SVG 'value by year' bar chart (202
·
back to La Permits School
·
Add enrich_live_contractor.py: recover LIVE contractor+CSLB 69d607c →