Mapping served populations for grant reporting without retaining PII
Geocode served addresses, attach boundary codes, aggregate counts, then drop the raw PII. A complete grant-reporting workflow with REST examples.
Grant reporting lives in an awkward middle ground. The programme officer filing the impact report knows which county served the most clients. The compliance officer knows that a CSV of client home addresses is PII that should never sit in a reporting database. The database administrator knows that "we geocoded everything into a spreadsheet" is not a data architecture. And the funder wants a map.
These four people are usually the same person, in a medium-sized nonprofit, on a tight timeline, with no GIS budget.
This post shows the complete workflow for getting from intake records — a list of the people or sites your organisation served — to a clean aggregate: counts by county, postcode, congressional district, or any other administrative boundary the funder specifies. The pattern uses reverse geocoding to attach boundary codes to coordinates, the CSV2GEO Boundaries endpoint to retrieve the shapes and ancestor hierarchy, and a simple aggregation step that runs in memory before the precise addresses are dropped. By the time anything is written to a reporting table, the only thing stored is a count per boundary code. The raw addresses never leave the ETL process.
Why grant officers want boundaries, not coordinates
A funder distributing a community health grant across a state has a measurement problem. They gave money to thirty organisations. Each organisation served between two hundred and two thousand clients. They need to demonstrate to their own board — and to the federal agency that gave them the sub-grant — that money reached underserved counties.
The measurement instrument the funder chose is almost always a named administrative division: county, ZIP code tabulation area, state legislative district, census tract. The reason is practical. Boundary-level counts can be compared to publicly-available population denominators from national statistical agencies. A "clients served per 1,000 residents" rate is a defensible impact metric. Raw client coordinates are not comparable to anything public without a join that creates new PII exposure.
So the deliverable is always some form of: "we served N people in County X." Getting there from intake records — which typically contain a name, an address, a date of service, and a service type — requires attaching an administrative code to each address and then counting. That is the entirety of the technical problem. The rest is not getting the data architecture wrong in the process.
What CSV2GEO provides
Three endpoints do the full job. None of them require you to ship PII beyond the individual call.
`GET /api/v1/geocode` — forward geocode a text address to a WGS-84 coordinate with confidence score and a partial administrative hierarchy in the response. This is the right first call when your intake records contain free-text addresses but no lat/lng. The response includes county, state, postcode, and country fields alongside lat and lng. For many grant-reporting use cases you do not need to call a boundaries endpoint at all — if county-level aggregation is sufficient, the geocode response already carries the county name and FIPS code.
`GET /api/v1/reverse` — reverse geocode a coordinate to the nearest address and its administrative context. Use this when intake records were collected with a GPS device or a mobile form that saved lat/lng but not a structured address. The response shape is identical to the forward geocode response.
`GET /api/v1/boundaries` — given a coordinate, return the full administrative ancestor chain (country → state → county → place → postcode) with boundary names and canonical codes for each level. Use this when the funder requires a boundary level that is not reliably populated in the geocode response — for instance, congressional district or state senate district — or when you need to attach multiple boundary codes in one call rather than parsing the geocode response at several levels.
The combination is: geocode or reverse-geocode each intake record to get a coordinate, call the boundaries endpoint to attach the right administrative codes, aggregate by those codes in memory, then drop the raw record. One row of intake data becomes one increment to a counter; the counter is what you store.
The data architecture that keeps you clean
Before writing a line of code, settle on this invariant: no precise address or individual-level record touches a persistent store in the reporting pipeline. The ETL process reads intake records from the authoritative source (your CRM, your intake form database, your case-management system), runs each record through the geocoding and boundary-attachment steps in memory, emits an increment to an in-memory counter keyed by boundary code, and writes only the final counts.
That invariant makes the pipeline safe to run from a shared server, safe to log with standard APM tooling, and safe to give to a programme officer who does not need to think about PHI handling every time they trigger a report.
The data flow looks like this:
CRM / intake DB
|
v
[Read batch of records — addresses, service dates, service types]
|
v
[Geocode each address] — coordinate + confidence score
|
v
[Attach boundary codes] — county FIPS, postcode, district ID
|
v
[Aggregate in memory] — counts[boundary_code][service_type] += 1
|
v
[Write counts to reporting DB] — no PII, no coordinates
|
v
Funder report / dashboardThe step labelled "aggregate in memory" is the moment PII exposure ends. Nothing after that arrow contains information that identifies any individual.
Step 1: Forward-geocode intake addresses in batches
Pull a batch of intake records. For this example we assume a CRM export with columns record_id, address, service_type, service_date. The goal is to attach lat, lng, confidence, county_fips, postcode to each row, then immediately discard record_id and address.
import csv
import os
import requests
from collections import defaultdict
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
def geocode_address(address: str) -> dict:
r = requests.get(
f"{API}/geocode",
params={"q": address, "api_key": KEY},
timeout=20,
)
r.raise_for_status()
results = r.json().get("results", [])
if not results:
return {}
return results[0]
# counts[county_fips][service_type] = integer count
counts = defaultdict(lambda: defaultdict(int))
low_confidence = 0
with open("intake_export.csv") as f:
for row in csv.DictReader(f):
geo = geocode_address(row["address"])
if not geo:
low_confidence += 1
continue
confidence = geo.get("confidence", 0)
if confidence < 0.7:
low_confidence += 1
continue # flag for manual review; do not count ambiguous records
county_fips = geo.get("county_fips") or geo.get("county")
postcode = geo.get("postcode")
service_type = row["service_type"]
# aggregate — the individual record is never written anywhere
counts[county_fips][service_type] += 1
# also aggregate by postcode for a second breakdown
counts[f"zip:{postcode}"][service_type] += 1
print(f"Records with low geocoding confidence (excluded): {low_confidence}")The key discipline is the comment on line counts[county_fips][service_type] += 1. At no point is row["address"], row["record_id"], or the coordinate written to the counts dict. The counter only knows that *some record* with service type *X* was served in county *Y*.
In production, replace the one-at-a-time geocode call with batched requests. The geocode endpoint accepts q as a list when you use the batch surface — check the API reference for the batch geocoding endpoint, which returns results in input order and keeps the per-request credit cost identical to individual calls.
Step 2: Attach richer boundary codes when county is not enough
Some funder reports require a boundary level that is not reliably populated in a forward geocode response — congressional district is the most common example in US federal grant reporting. For those, call the Boundaries endpoint per coordinate after geocoding.
curl -G "https://csv2geo.com/api/v1/boundaries" \
--data-urlencode "lat=38.8977" \
--data-urlencode "lng=-77.0365" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"The response returns the full administrative ancestor chain for that point:
{
"results": {
"country": {"name": "United States", "code": "US"},
"state": {"name": "District of Columbia", "code": "DC", "fips": "11"},
"county": {"name": "District of Columbia", "fips": "11001"},
"postcode": {"code": "20500"},
"place": {"name": "Washington"}
}
}In Python, after geocoding a batch and collecting the coordinates, loop through them to attach boundary codes:
def get_boundary_codes(lat: float, lng: float) -> dict:
r = requests.get(
f"{API}/boundaries",
params={"lat": lat, "lng": lng, "api_key": KEY},
timeout=20,
)
r.raise_for_status()
return r.json().get("results", {})And in Node if your reporting pipeline runs in JavaScript:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
async function getBoundaryCodes(lat, lng) {
const url = `${API}/boundaries?lat=${lat}&lng=${lng}&api_key=${KEY}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`boundaries ${r.status}`);
const body = await r.json();
return body.results ?? {};
}The call returns synchronously and the response is small (a few hundred bytes of JSON). It is fast enough to call per-record in a streaming pipeline if your intake volume is in the thousands rather than the hundreds of thousands. For larger volumes, batch your geocoding first, collect all the coordinates you need, then fan out the boundaries calls with a concurrency limit of 10-20. See Concurrency Tuning for Geocoding Pipelines for the concurrency model that keeps you below the rate limiter without artificially serialising the work.
Step 3: Handle low-confidence geocodes without skewing the count
Geocoding confidence matters differently in grant reporting than it does in, say, routing. A bad geocode in routing puts a vehicle on the wrong street. A bad geocode in grant reporting puts a served client in the wrong county — which can flip whether you met the funder's target for an underserved area.
The right handling is a three-way branch on the confidence score:
def confidence_bucket(score: float) -> str:
if score >= 0.85:
return "high" # count it; trust the county
if score >= 0.70:
return "medium" # count it at postcode level; exclude from county counts
return "low" # exclude from all counts; flag for manual address verificationRecords in the low bucket go to a manual review queue. In practice, for a well-maintained address database, fewer than 5% of records fall below 0.70 — but that 5% is worth checking, because it tends to cluster around addresses that are misformatted, rural route numbers, or post-office boxes that do not resolve to a physical location. A grant report that counts a PO box as a served county resident is exactly the kind of thing that fails a site visit.
For a deeper treatment of what confidence scores actually measure, see Geocoding Confidence Scores Explained.
Step 4: Aggregate in memory and write only counts
Once boundary codes are attached, the aggregation is a counter. Nothing exotic:
from collections import defaultdict
import json
# Accumulate during the ETL loop (see Step 1)
# county_counts[county_fips][service_type] = int
# postcode_counts[postcode][service_type] = int
# After the loop, write the aggregated result to the reporting store.
# This is the only thing that persists. No PII, no coordinates.
report = {
"by_county": {
fips: dict(service_breakdown)
for fips, service_breakdown in county_counts.items()
},
"by_postcode": {
code: dict(service_breakdown)
for code, service_breakdown in postcode_counts.items()
},
"total_served": sum(
sum(v.values()) for v in county_counts.values()
),
"excluded_low_confidence": low_confidence,
}
with open("grant_report_counts.json", "w") as out:
json.dump(report, out, indent=2)The output is a JSON file with no individual records, no names, no addresses, no coordinates. It contains county FIPS codes, postcode strings, service-type labels, and integer counts. A programme officer can read it, a funder can verify it against publicly-available service-area population data, and a compliance officer can confirm that it contains no PII.
If your reporting database is SQL rather than JSON files, write one row per (county_fips, service_type, report_period) with a count column. The schema is five columns. The entire table for a year of monthly reports and twenty service types across forty counties is well under a million rows — trivially small.
Step 5: Render the counts as a funder-ready deliverable
The counts JSON above is the technical deliverable. Funders typically want one of three presentations:
A table. Paste the county-level counts into a spreadsheet. Add a column with the county name (decoded from the FIPS code using any standard FIPS reference table — this is not PII and does not require a geocoding call). Add a column with the total population of that county from any public statistical source. Compute the rate. Done.
A choropleth map. Load the counts JSON alongside a standard county or postcode boundary shapefile. Colour each boundary by count or rate. Most grant-reporting teams use a free GIS viewer or a Python mapping library for this step — the output is a static PNG or PDF that drops into the funder's reporting portal.
A narrative summary. "We served N clients across X counties. The three highest-volume counties were A (N₁), B (N₂), and C (N₃), together representing Y% of total service volume." This can be generated programmatically from the counts JSON and requires no PII access at all — a programme officer who never saw the original intake records can write this paragraph from the JSON alone.
All three presentations derive from the same source: the counts table you wrote in Step 4. No PII surfaces in any of them.
Caching the boundary lookups
If your intake records are geocoded incrementally — new records processed each night — you will repeatedly call the Boundaries endpoint for coordinates that fall within the same county. The county boundary does not move. Cache the result.
A reasonable cache key is the county FIPS code returned by the initial geocode call. If two records geocode to the same FIPS code, you already know the boundary hierarchy without calling the Boundaries endpoint again. Only call it for the first record in each FIPS that you encounter in a given batch.
For postcode-level boundary enrichment, cache by postcode. A five-digit US postcode maps to a fixed set of ancestor boundaries — it does not change between runs.
The broader caching argument, with numbers, is in Caching Geocoding Results — 90% Cost Reduction. The short version: in a nonprofit intake pipeline where a significant fraction of clients live in the same few postcodes, a postcode-level cache hits at 60-80%. The real per-record API cost for boundary enrichment drops accordingly.
Handling multi-site programmes
Some grant programmes fund services delivered at physical sites — food pantries, health clinics, mobile units — rather than at client home addresses. The reporting unit is the site, not the client. The workflow is slightly different.
You geocode each site address once, attach its boundary codes once, and then count service events at that site rather than geocoding each service event. The ETL loop becomes:
- Build a lookup table:
site_id → (county_fips, postcode, confidence)— geocode once per site, cache indefinitely. - Read service records:
site_id, service_type, service_date, client_count. - For each service record, look up the boundary codes from the cache and add
client_countto the counter. - Write counts.
No client address is ever touched. The geocoding happens at the site level, which involves no PII at all — site addresses are publicly-listed addresses of your own facilities. This is also the pattern for organisations that serve clients via telephone or telemedicine and collect no address at all: geocode the site, count the service events there, report counts by site's administrative boundary.
The same applies to grant programmes that count service delivery points rather than people — the number of meals served, the number of visits completed, the number of referrals made. The boundary-attachment and aggregation logic is identical; only the unit in the counter changes.
What this pipeline does not do — and what to do instead
Be precise about scope.
The pipeline does not produce demographic profiles. You will end up with counts by county. You will not end up with the demographic composition of the people you served, unless you collected that data in intake (many grant programmes require it) and aggregate it separately before dropping it. CSV2GEO does not provide a demographics endpoint — it tells you where a boundary is, not what is inside it. Demographic enrichment against publicly-available population data is a separate JOIN in your reporting query, not a geocoding operation.
The pipeline does not validate that your service area matches the grant agreement. A grant may restrict services to clients whose primary residence falls within a specific set of counties. The pipeline will produce counts per county, but it will not tell you whether those clients were eligible under the grant terms — that is a query against your eligibility rules, not a geocoding question.
The pipeline does not replace a formal PII data governance review. The architecture described here — aggregate in memory, write only counts — is a sound technical pattern for limiting PII exposure, but whether it satisfies a specific funder's data-use agreement or your organisation's privacy policy is a question for your compliance team. This post is engineering guidance, not legal advice.
Geocoding accuracy is not uniform across address types. Rural route addresses, tribal land addresses, and addresses in US territories resolve with lower confidence than urban grid addresses. A programme that specifically serves those populations will see a higher rate of low-confidence geocodes and a higher exclusion rate. Budget for manual address verification at the intake step, not at the reporting step — by the time you are building the grant report, it is too late to correct a bad address in the source system. For a longer treatment of accuracy by geography, see Reverse Geocoding Accuracy in Meters.
Cost model for a realistic intake volume
A mid-sized nonprofit serving 5,000 people per year across a three-county programme area runs the following API calls:
- 5,000 forward geocode calls (one per client address): 5,000 credits
- Boundaries calls: one per unique postcode encountered. In a three-county area, you might see 40 unique postcodes — so 40 credits, cached for the rest of the year.
- Total for the year: roughly 5,040 credits.
The free tier covers 3,000 calls per day, which means a programme of this size could run the entire annual geocoding job within a single day's free allocation — no credit card required. Larger programmes — regional organisations serving 50,000 clients across 30 counties — still fit comfortably within the entry paid tier at $54/month for 100,000 calls, with room to spare for monthly report refreshes.
See the live pricing at csv2geo.com/pricing/api. There is no quote process — the published tiers are the prices.
Frequently Asked Questions
Do I need a paid plan to run this for a small grant report?
Not necessarily. The free tier provides 3,000 calls per day. A programme serving under 3,000 clients per year can geocode the entire intake dataset within the free allocation. If your annual intake is larger, run the job in batches spread across multiple days, or move to a paid plan where the economics are still very favourable for nonprofit volumes.
Does CSV2GEO store the addresses I send through the geocoding API?
CSV2GEO's no_record mode is designed for sensitive use cases where you do not want queries persisted in server logs. Regardless of mode, the pipeline architecture described here — aggregate in memory, write only counts — means that even if a request were logged, the log entry contains only the address string and the boundary code returned, not any combination of address with a named individual. For programmes under stricter data-use agreements, review the no_record option in the HIPAA-safe geocoding post alongside your compliance team.
What if the funder requires congressional district counts rather than county counts?
Use the Boundaries endpoint instead of relying solely on the geocode response fields. The Boundaries endpoint returns the full administrative hierarchy including congressional and state legislative district IDs where available. The aggregation logic is identical — substitute the district ID as your counter key.
How do I handle duplicate client records — the same person served multiple times?
That depends on whether the grant counts people served (unique clients) or service events (total visits). For people served, deduplicate on client ID before geocoding — only geocode each unique client once, and aggregate one count per client per county. For service events, geocode each record and aggregate each event. The pipeline architecture is the same; the deduplication step is upstream of the geocoding call.
What if a client has multiple addresses on file — for instance, a shelter address and a home address?
Use whichever address your programme has designated as the "place of service" or "client home" for grant reporting purposes. This is a programme policy decision, not a geocoding decision. Document the choice in your methodology section of the grant report — funders increasingly ask to see the data methodology alongside the numbers.
Can I use this pipeline for reporting to multiple funders with different boundary requirements simultaneously?
Yes. The boundary code attachment step can emit multiple codes per record — county FIPS for one funder, congressional district for another, postcode for a third. Each counter is keyed independently. You run one geocoding pass per record, attach all the boundary codes in a single boundaries call, and maintain separate counter dicts for each funder's reporting geography. The end result is three different output JSONs from one ETL run.
What happens if a client's address does not resolve to a coordinate at all — the geocode returns no results?
Log the record as unresolved, count it in your exclusion total, and flag it for manual address verification in your intake system. A typical cause is a mistyped address, a PO box used as a residential address, or an address that is too new to appear in the address database. Do not invent a county assignment for unresolved records — an inflated count in the wrong county is worse for grant compliance than an honest exclusion note in the methodology.
Related Articles
- Reverse geocoding accuracy in meters — understanding what the distance error in geocoding actually means for boundary assignment
- Geocoding confidence scores explained — how to interpret the confidence field and set the right exclusion threshold for your data
- Caching geocoding results — 90% cost reduction — postcode and county boundary codes do not change; cache them and slash your per-report cost
- HIPAA-compliant geocoding — `no_record` and BAA — for programmes where the address itself is protected health information
- Observability for geocoding pipelines — what to instrument so you know when a batch run silently excludes more records than expected
---
*I.A. / CSV2GEO Creator*
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →