id: census-batch-address-geocoder-with-cache
namespace: company.team
description: |
Geocode a CSV of US addresses to coordinates and Census geographies (state, county, tract,
block) with the free Census Geocoder batch endpoint, and keep every answer in a Parquet cache
in Kestra's internal storage so re-running on a grown or re-ordered file sends only the
addresses the Census has not seen yet. No API key, no per-request loop: one multipart POST
of up to 10,000 addresses per run, parsed and merged with DuckDB.
inputs:
- id: addresses_csv
type: STRING
description: Inline CSV with a header row `id,street,city,state,zip`. Ignored
when `addresses_file` is uploaded. The default has one duplicate written
differently and one address that cannot match.
defaults: |
id,street,city,state,zip
1,1600 Pennsylvania Ave NW,Washington,DC,20500
2,350 Fifth Ave,New York,NY,10118
3,233 S Wacker Dr,Chicago,IL,60606
4,400 Broad St,Seattle,WA,98109
5, 1600 pennsylvania ave nw ,washington,dc,20500
6,999 Nowhere Street,Faketown,ZZ,00000
- id: addresses_file
type: FILE
required: false
description: Optional CSV upload with the same columns, for real address lists.
- id: benchmark
type: STRING
defaults: Public_AR_Current
description: Census address benchmark. `Public_AR_Current` is the latest; a
dated one such as `Public_AR_Census2020` reproduces an older vintage.
- id: vintage
type: STRING
defaults: Current_Current
description: Census geography vintage paired with the benchmark
(`Census2020_Census2020` with the 2020 benchmark).
- id: cache_key
type: STRING
defaults: census_geocode_cache
description: KV key holding the cache URI. Use a different key per address list
or per benchmark.
- id: recheck_no_match
type: BOOL
defaults: false
description: Send cached No_Match and Tie addresses again (useful after the
Census publishes a new benchmark).
tasks:
- id: inline_addresses
type: io.kestra.plugin.core.storage.Write
description: Puts the inline CSV in internal storage so DuckDB reads the inline
text and an uploaded file the same way.
content: "{{ inputs.addresses_csv }}"
extension: .csv
- id: previous_cache
type: io.kestra.plugin.core.kv.Get
key: "{{ inputs.cache_key }}"
errorOnMissing: false
description: URI of the Parquet cache written by the last run; missing on the first run.
- id: bootstrap
type: io.kestra.plugin.core.flow.If
description: First run only. Writes an empty cache with the right schema so
every later task has one code path.
condition: "{{ outputs.previous_cache.value is not defined or
outputs.previous_cache.value is empty }}"
then:
- id: empty_cache
type: io.kestra.plugin.jdbc.duckdb.Queries
url: "jdbc:duckdb:"
fetchType: NONE
outputFiles:
- cache_parquet
sql: |
CREATE TABLE cache (
address_key VARCHAR, match_status VARCHAR, match_type VARCHAR, matched_address VARCHAR,
longitude DOUBLE, latitude DOUBLE, tigerline_id VARCHAR, side VARCHAR,
state_fips VARCHAR, county_fips VARCHAR, tract VARCHAR, block VARCHAR,
benchmark VARCHAR, geocoded_at TIMESTAMP
);
COPY cache TO '{{ outputFiles.cache_parquet }}' (FORMAT PARQUET);
- id: build_batch
type: io.kestra.plugin.jdbc.duckdb.Queries
description: |
Normalises each address into a key (upper case, trimmed, single spaces), drops duplicates, removes the keys
already in the cache and writes the Census batch file (no header, `id,street,city,state,zip`, at most 10,000 rows)
plus a key map so the response can be joined back without trusting the echoed address text.
url: "jdbc:duckdb:"
fetchType: FETCH_ONE
inputFiles:
addresses.csv: "{{ inputs.addresses_file ?? outputs.inline_addresses.uri }}"
cache.parquet: "{{ outputs.previous_cache.value ??
outputs.empty_cache.outputFiles.cache_parquet }}"
outputFiles:
- batch_csv
- keys_csv
parameters:
recheck_no_match: "{{ inputs.recheck_no_match }}"
sql: |
CREATE TABLE addresses AS
SELECT *, upper(regexp_replace(trim(street) || '|' || trim(city) || '|' || trim(state) || '|' || trim(zip), '\s+', ' ', 'g')) AS address_key
FROM read_csv('addresses.csv', header = true, all_varchar = true);
CREATE TABLE cache AS SELECT * FROM read_parquet('cache.parquet');
CREATE TABLE pending AS
SELECT address_key, any_value(trim(street)) AS street, any_value(trim(city)) AS city, any_value(trim(state)) AS state, any_value(trim(zip)) AS zip
FROM addresses a
WHERE NOT EXISTS (
SELECT 1 FROM cache c
WHERE c.address_key = a.address_key
AND NOT (:recheck_no_match AND c.match_status <> 'Match')
)
GROUP BY address_key
ORDER BY address_key;
CREATE TABLE batch AS
SELECT CAST(row_number() OVER (ORDER BY address_key) AS VARCHAR) AS batch_id, * FROM pending LIMIT 10000;
COPY (SELECT batch_id, street, city, state, zip FROM batch ORDER BY CAST(batch_id AS INTEGER)) TO '{{ outputFiles.batch_csv }}' (HEADER false, DELIMITER ',');
COPY (SELECT batch_id, address_key FROM batch) TO '{{ outputFiles.keys_csv }}' (HEADER true, DELIMITER ',');
SELECT
(SELECT count(*) FROM addresses) AS input_rows,
(SELECT count(DISTINCT address_key) FROM addresses) AS unique_addresses,
(SELECT count(DISTINCT address_key) FROM addresses) - (SELECT count(*) FROM pending) AS from_cache,
(SELECT count(*) FROM batch) AS to_geocode,
(SELECT count(*) FROM pending) - (SELECT count(*) FROM batch) AS deferred,
(SELECT count(*) FROM cache) AS cache_size_before;
- id: geocode_new
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.build_batch.outputs[0].row.to_geocode > 0 }}"
then:
- id: geocode
type: io.kestra.plugin.core.http.Download
description: |
One multipart POST with the batch file; the Census returns a CSV (`GeocodeResults.csv`) with the match status,
standardised address, longitude,latitude and the state, county, tract and block FIPS codes. A 10,000-row batch
can take several minutes, hence the long read timeout.
uri: https://geocoding.geo.census.gov/geocoder/geographies/addressbatch
method: POST
contentType: multipart/form-data
formData:
addressFile:
name: addresses.csv
content: "{{ outputs.build_batch.outputFiles.batch_csv }}"
benchmark: "{{ inputs.benchmark }}"
vintage: "{{ inputs.vintage }}"
options:
timeout:
readIdleTimeout: PT10M
- id: merge_cache
type: io.kestra.plugin.jdbc.duckdb.Queries
description: Parses the Census CSV (No_Match and Tie rows have only three
columns), joins the batch ids back to the address keys and writes the
new cache as Parquet.
url: "jdbc:duckdb:"
fetchType: FETCH_ONE
inputFiles:
response.csv: "{{ outputs.geocode.uri }}"
keys.csv: "{{ outputs.build_batch.outputFiles.keys_csv }}"
cache.parquet: "{{ outputs.previous_cache.value ??
outputs.empty_cache.outputFiles.cache_parquet }}"
outputFiles:
- cache_parquet
parameters:
benchmark: "{{ inputs.benchmark }}"
run_started: "{{ execution.startDate }}"
sql: |
CREATE TABLE response AS
SELECT * FROM read_csv('response.csv', header = false, delim = ',', quote = '"', escape = '"', null_padding = true, auto_detect = false, all_varchar = true,
columns = {'batch_id': 'VARCHAR', 'input_address': 'VARCHAR', 'match_status': 'VARCHAR', 'match_type': 'VARCHAR',
'matched_address': 'VARCHAR', 'coordinates': 'VARCHAR', 'tigerline_id': 'VARCHAR', 'side': 'VARCHAR',
'state_fips': 'VARCHAR', 'county_fips': 'VARCHAR', 'tract': 'VARCHAR', 'block': 'VARCHAR'});
CREATE TABLE fresh AS
SELECT k.address_key, r.match_status, r.match_type, r.matched_address,
CAST(split_part(r.coordinates, ',', 1) AS DOUBLE) AS longitude,
CAST(split_part(r.coordinates, ',', 2) AS DOUBLE) AS latitude,
r.tigerline_id, r.side, r.state_fips, r.county_fips, r.tract, r.block,
:benchmark AS benchmark, CAST(:run_started AS TIMESTAMP) AS geocoded_at
FROM response r JOIN read_csv('keys.csv', header = true, all_varchar = true) k ON k.batch_id = r.batch_id;
CREATE TABLE cache AS
SELECT * FROM read_parquet('cache.parquet') c WHERE NOT EXISTS (SELECT 1 FROM fresh f WHERE f.address_key = c.address_key)
UNION ALL
SELECT * FROM fresh;
COPY cache TO '{{ outputFiles.cache_parquet }}' (FORMAT PARQUET);
SELECT
(SELECT count(*) FROM fresh) AS geocoded,
(SELECT count(*) FILTER (WHERE match_status = 'Match') FROM fresh) AS matched,
(SELECT count(*) FILTER (WHERE match_type = 'Exact') FROM fresh) AS exact,
(SELECT count(*) FILTER (WHERE match_type = 'Non_Exact') FROM fresh) AS non_exact,
(SELECT count(*) FILTER (WHERE match_status = 'No_Match') FROM fresh) AS no_match,
(SELECT count(*) FILTER (WHERE match_status = 'Tie') FROM fresh) AS tie,
(SELECT count(*) FROM cache) AS cache_size;
- id: save_cache
type: io.kestra.plugin.core.kv.Set
key: "{{ inputs.cache_key }}"
value: "{{ outputs.merge_cache.outputFiles.cache_parquet }}"
description: Points the KV key at the new cache. Old cache files stay with their
executions until purged.
- id: join_output
type: io.kestra.plugin.jdbc.duckdb.Queries
description: Joins every input row (duplicates included, original order kept) to
the cache and writes the geocoded CSV with coordinates, FIPS codes and a
15-digit block GEOID.
url: "jdbc:duckdb:"
fetchType: FETCH_ONE
inputFiles:
addresses.csv: "{{ inputs.addresses_file ?? outputs.inline_addresses.uri }}"
cache.parquet: "{{ outputs.merge_cache.outputFiles.cache_parquet ??
outputs.previous_cache.value ??
outputs.empty_cache.outputFiles.cache_parquet }}"
outputFiles:
- geocoded_csv
parameters:
run_started: "{{ execution.startDate }}"
sql: |
CREATE TABLE addresses AS
SELECT *, upper(regexp_replace(trim(street) || '|' || trim(city) || '|' || trim(state) || '|' || trim(zip), '\s+', ' ', 'g')) AS address_key,
row_number() OVER () AS input_order
FROM read_csv('addresses.csv', header = true, all_varchar = true);
CREATE TABLE result AS
SELECT a.id, a.street, a.city, a.state, a.zip,
coalesce(c.match_status, 'PENDING') AS match_status, c.match_type, c.matched_address,
c.longitude, c.latitude, c.state_fips, c.county_fips, c.tract, c.block,
CASE WHEN c.block IS NOT NULL THEN c.state_fips || c.county_fips || c.tract || c.block END AS block_geoid,
c.benchmark, c.geocoded_at,
c.geocoded_at < CAST(:run_started AS TIMESTAMP) AS from_cache
FROM addresses a
LEFT JOIN read_parquet('cache.parquet') c ON c.address_key = a.address_key
ORDER BY a.input_order;
COPY result TO '{{ outputFiles.geocoded_csv }}' (HEADER true, DELIMITER ',');
SELECT
count(*) AS rows,
count(*) FILTER (WHERE match_status = 'Match') AS matched,
count(*) FILTER (WHERE match_type = 'Exact') AS exact,
count(*) FILTER (WHERE match_type = 'Non_Exact') AS non_exact,
count(*) FILTER (WHERE match_status = 'No_Match') AS no_match,
count(*) FILTER (WHERE match_status = 'Tie') AS tie,
count(*) FILTER (WHERE match_status = 'PENDING') AS pending,
count(*) FILTER (WHERE from_cache) AS from_cache,
round(100.0 * count(*) FILTER (WHERE match_status = 'Match') / count(*), 1) AS match_rate_pct
FROM result;
- id: summary
type: io.kestra.plugin.core.log.Log
message: |
{{ outputs.build_batch.outputs[0].row.input_rows }} input rows, {{ outputs.build_batch.outputs[0].row.unique_addresses }} unique addresses: {{ outputs.build_batch.outputs[0].row.from_cache }} already cached, {{ outputs.build_batch.outputs[0].row.to_geocode }} sent to the Census Geocoder, {{ outputs.build_batch.outputs[0].row.deferred }} deferred to the next run.
Result: {{ outputs.join_output.outputs[0].row.matched }} matched ({{ outputs.join_output.outputs[0].row.exact }} exact, {{ outputs.join_output.outputs[0].row.non_exact }} non-exact), {{ outputs.join_output.outputs[0].row.no_match }} no match, {{ outputs.join_output.outputs[0].row.tie }} tie, {{ outputs.join_output.outputs[0].row.pending }} pending; match rate {{ outputs.join_output.outputs[0].row.match_rate_pct }} percent.
Geocoded CSV: {{ outputs.join_output.outputFiles.geocoded_csv }}
outputs:
- id: geocoded_csv
type: FILE
value: "{{ outputs.join_output.outputFiles.geocoded_csv }}"
- id: batch_summary
type: JSON
value: "{{ outputs.build_batch.outputs[0].row }}"
- id: result_summary
type: JSON
value: "{{ outputs.join_output.outputs[0].row }}"
- id: cache_parquet
type: FILE
value: "{{ outputs.merge_cache.outputFiles.cache_parquet ??
outputs.previous_cache.value ??
outputs.empty_cache.outputFiles.cache_parquet }}"