DEV Community

Daniel Pertu
Daniel Pertu

Posted on

16 Parquet files in a bucket we do not own, and the only client is DuckDB

Nakodo finds creators for brands and emails the ones that fit. On the paid plans it does the same thing for local businesses: you pick the kinds of business and the places, and it goes looking for pubs, cafes, gyms, salons, dentists, whatever you chose. That feature needs something the creator side never needed, which is a listing of businesses with addresses and websites.

The obvious options are commercial places APIs. We did not want one. The pricing is per lookup, the terms usually forbid storing results, and a product whose whole job is to run a search in the background for weeks is the worst possible shape for a per-call meter.

So we went to Overture Maps instead. Overture publishes an open places dataset, built from Meta, Microsoft, Foursquare and AllThePlaces contributions, as GeoParquet files on a public S3 bucket, with a new release roughly every month. No key, no account, no quota.

What is actually in the bucket

Releases sit under release/<date>.<n>/. The places theme of the release we are on right now, 2026-09-23.1, is 16 files:

SET s3_region='us-west-2';
SELECT count(*) AS files
FROM glob('s3://overturemaps-us-west-2/release/2026-09-23.1/theme=places/type=place/*');
-- 16
Enter fullscreen mode Exit fullscreen mode

Sixteen files for every business on Earth is a lot of bytes. The thing that makes this usable from a small app is that Parquet is columnar and chunked into row groups, each one carrying min and max statistics for its columns, and Overture writes a bbox struct on every place. So a query with a bounding box in it does not read the dataset. It reads the footers, works out which row groups could possibly contain a place inside that box, and fetches only those ranges over HTTP.

DuckDB with httpfs does all of that for you. Here is the entire client, and it is worth pasting into a duckdb shell rather than taking my word for it:

INSTALL httpfs; LOAD httpfs;
SET s3_region='us-west-2';
SET enable_http_metadata_cache = true;
SET parquet_metadata_cache = true;
.timer on

-- Bars, pubs and gastropubs in a half degree box over Leeds.
SELECT count(*)
FROM read_parquet('s3://overturemaps-us-west-2/release/2026-09-23.1/theme=places/type=place/*')
WHERE bbox.xmin >= -2   AND bbox.xmax <= -1.5
  AND bbox.ymin >= 53.5 AND bbox.ymax <= 54
  AND list_has_any(taxonomy.hierarchy, ['bar'])
  AND confidence >= 0.5;
Enter fullscreen mode Exit fullscreen mode

On a laptop on home broadband, that is 2,179 rows in 45.4 seconds. Then, in the same session, change the box to the cell next door over Manchester and run it again: 1,626 rows in 4.7 seconds. Change the category to cafe and keep the Leeds box: 1,042 rows in 0.64 seconds.

Those three numbers are the whole architectural argument. The first query pays for sixteen Parquet footers. The second pays for new row groups but not the footers. The third pays for almost nothing, because the row groups covering Leeds are already in the cache and cafes live in the same ones as bars.

That is why the connection is a module-level promise and not a per-request thing:

let connection: Promise<DuckDBConnection> | null = null;

function connect(): Promise<DuckDBConnection> {
  connection ??= (async () => {
    const { DuckDBInstance } = await import("@duckdb/node-api");
    const instance = await DuckDBInstance.create(":memory:", {
      home_directory: "/tmp",
      extension_directory: "/tmp/duckdb_extensions",
      memory_limit: "1200MB",
      threads: "4",
    });
    const c = await instance.connect();
    await c.run("INSTALL httpfs; LOAD httpfs;");
    await c.run("SET enable_http_metadata_cache = true; SET parquet_metadata_cache = true; SET http_timeout = 60000;");
    return c;
  })().catch((e) => {
    connection = null;
    throw e;
  });
  return connection;
}
Enter fullscreen mode Exit fullscreen mode

Two details in there are scars. extension_directory has to be under /tmp, because that is the only writable directory in the environments this runs in, and INSTALL httpfs wants to write. And the .catch resets the module variable, because a cached rejected promise means every later call in that process fails with a connection error that happened once, minutes ago.

This does not run in a web request

A whole country can take minutes, and @duckdb/node-api is a native addon rather than something a bundler can inline. Neither of those fits a serverless function with a request timeout, so the import is the one piece of Nakodo that lives on Trigger.dev while the rest of the pipeline stays in a Postgres-backed cron queue:

export default defineConfig({
  dirs: ["./src/trigger"],
  runtime: "node-24",
  maxDuration: 1800,
  machine: "medium-1x",
  build: {
    // DuckDB is a native addon: installed in the image, not bundled.
    external: ["@duckdb/node-api"],
  },
});
Enter fullscreen mode Exit fullscreen mode

memory_limit: "1200MB" is deliberately well under the 2 GB that machine has, because DuckDB's limit governs DuckDB and the Node heap still needs room to hold the rows we are converting. The task itself caps concurrency at two runs, since each one is holding file metadata for the whole dataset.

Rows come out as a stream, and go in in batches

The reader is an async generator rather than a function returning an array, because the point of a batch is that a country never sits in memory at once:

export async function* readPlaces(opts: {
  release: string; bbox: Bbox; categories: string[]; countryCode?: string;
}): AsyncGenerator<OverturePlace[]> { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The writer then does something that looks redundant and is not:

for await (const found of readPlaces({ release, bbox, categories, countryCode })) {
  // One insert can't update the same row twice.
  const rows = [...new Map(found.map((f) => [f.id, toRow(f, release, now)])).values()];
  for (let i = 0; i < rows.length; i += 500) {
    await db.insert(places).values(rows.slice(i, i + 500))
      .onConflictDoUpdate({ target: places.id, set: UPDATE_ON_IMPORT });
  }
}
Enter fullscreen mode Exit fullscreen mode

The new Map deduplication is not defensive tidying. Postgres will not let a single INSERT ... ON CONFLICT DO UPDATE touch the same target row twice; it raises ON CONFLICT DO UPDATE command cannot affect row a second time. Overture's ids are stable across releases, and a bounding box that overlaps a file boundary can hand you the same id twice in one batch, so the statement has to be made internally unique before it is sent. The place to do that is the batch, not the database.

A release also has to be able to forget. After the insert, places in that box and those categories whose release is not the current one are deleted: a shop that closed, or a record Overture merged into another. Rows that have already become a lead in someone's campaign keep their own copy of what mattered, so a deletion in the listing never deletes a conversation.

The two numbers we throw rows away on

Overture ships a confidence between 0 and 1 and an operating_status. The reader will not return anything under 0.5, the pipeline will not consider anything under 0.6, and anything not open is skipped. That is the least interesting code in the feature and probably the highest value per line: a listing with no confidence floor sends email to businesses that do not exist.

Licences, because this is somebody else's work

Overture's places data comes under CDLA-Permissive 2.0 for the Meta and Microsoft contributions, Apache 2.0 for Foursquare and CC0 for AllThePlaces. Storage and reuse are fine. Attribution is not optional, and it belongs on the product rather than in a source comment.

Go and read the public version

Everything above has a plain-English counterpart on Nakodo's own methods page, which is the document we hold ourselves to: nakodo.app/how-it-works#businesses. It names Overture Maps, says the listing is released monthly, says we leave out listings Overture is unsure about, and states the 200-businesses-a-day ceiling per campaign and the rule that treats a listed brand, or a website shared by more than three places, as a chain.

Read that section, then run the DuckDB query above against the same release. You will be querying the same 16 files we do.

Top comments (0)