Parallel Geocoding With Multiprocessing in Python

TL;DR: Use concurrent.futures.ProcessPoolExecutor (or multiprocessing.Pool) to parallelize the CPU-bound part of a geocoding job — address parsing, Unicode normalization, and canonical-key generation — across all cores, then hand the cleaned, deduplicated records to an async client for the I/O-bound API calls. Multiprocessing sidesteps the GIL for CPU work; it does not speed up network calls, where asyncio wins. This page supports optimizing batch geocoding throughput.

When Multiprocessing Is the Right Tool

The GIL lets only one thread execute Python bytecode at a time, so CPU-bound work — regex parsing, unicodedata normalization, libpostal calls, fuzzy key building — cannot scale across cores under threads or asyncio. Separate processes each own an independent interpreter and GIL, so N workers on N cores give roughly N-times throughput for that CPU work.

Geocoding a batch has two distinct cost profiles, and they want opposite tools:

Stage Cost profile Right tool Why
Parse, normalize, canonical-key CPU-bound ProcessPoolExecutor One GIL per process; scales with cores
Call the geocoding API I/O-bound (network wait) asyncio + bounded semaphore Thousands of waits on one core; no pickling cost

If your normalization is trivial (a .strip().lower()) the parallelization overhead outweighs the gain — keep it in-process. Reach for multiprocessing only when per-record CPU cost is meaningful, such as libpostal parsing described in normalizing international addresses with libpostal.

Processes, Threads or the Event Loop

The three concurrency models solve different problems, and geocoding pipelines contain work for all three. Choosing by habit rather than by the bottleneck is how teams end up running thirty-two worker processes that spend their lives waiting on sockets.

Which concurrency model each pipeline stage wants Three panels. Processes suit CPU-bound work such as parsing, normalisation and hashing, and bypass the global interpreter lock at the cost of serialisation between workers. Asyncio suits network-bound provider calls with thousands of concurrent waits on one thread. Threads suit blocking library calls that release the lock, such as database drivers. processes for CPU-bound stages parsing · normalising · hashing · fuzzy scoring cost: pickling across the process boundary asyncio for network-bound stages provider calls, thousands of waits on one thread cost: the whole call path must be non-blocking threads for blocking libraries database drivers, C extensions releasing the lock cost: no help at all for pure-Python CPU work A realistic pipeline uses two: a process pool for the CPU stages and an event loop inside the dispatch stage.

The combination in the last line is worth spelling out, because it is not obvious: each worker process runs its own event loop, dispatching its share of the addresses concurrently. That gives you CPU parallelism across processes and I/O concurrency within each — without either model having to do the other’s job.

The ProcessPoolExecutor Pattern

Define the worker function at module top level (it must be importable so it can be pickled and shipped to workers), then stream records through executor.map with an explicit chunksize. map preserves input order; if you do not need order, imap_unordered via the low-level Pool returns results as they finish and keeps workers busier.

from __future__ import annotations

import re
import unicodedata
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass
from typing import Iterable, Iterator

# Compile regex once at module import — inherited by each worker process.
_MULTI_SPACE = re.compile(r"\s{2,}")
_PUNCT = re.compile(r"[^\w\s]")


@dataclass(frozen=True)
class NormalizedRecord:
    """CPU-bound normalization output, ready for cache lookup / geocoding."""
    raw: str
    canonical_key: str


def normalize_record(raw: str) -> NormalizedRecord:
    """CPU-bound normalization + canonical-key generation for one address.

    Runs inside a worker process. Must be top-level so it is picklable.

    Args:
        raw: Raw address string.

    Returns:
        A NormalizedRecord carrying the original text and its canonical key.
    """
    folded = unicodedata.normalize("NFKD", raw).casefold()
    folded = _PUNCT.sub(" ", folded)
    folded = _MULTI_SPACE.sub(" ", folded).strip()
    key = "|".join(sorted(folded.split()))  # order-independent canonical key
    return NormalizedRecord(raw=raw, canonical_key=key)


def normalize_batch(
    addresses: Iterable[str],
    workers: int = 4,
    chunksize: int = 2_000,
) -> Iterator[NormalizedRecord]:
    """Parallelize CPU-bound normalization across worker processes.

    Args:
        addresses: Iterable of raw address strings.
        workers: Number of worker processes (default: match physical cores).
        chunksize: Records shipped to a worker per task — amortizes pickling.

    Yields:
        NormalizedRecord objects in input order.
    """
    with ProcessPoolExecutor(max_workers=workers) as executor:
        yield from executor.map(normalize_record, addresses, chunksize=chunksize)


if __name__ == "__main__":
    import os

    sample = ["123 Main St, Apt 4", "123  main street apt 4", "Åby Vägen 7"]
    cores = os.cpu_count() or 4
    for rec in normalize_batch(sample, workers=cores, chunksize=1_000):
        print(rec.canonical_key)

The if __name__ == "__main__": guard is mandatory: on the spawn start method (the default on Windows and macOS) child processes re-import the module, and without the guard they would recursively spawn more pools.

imap_unordered variant

When result order does not matter — the usual case, since each record carries its own key — multiprocessing.Pool.imap_unordered yields results the instant any worker finishes, avoiding the head-of-line stall that ordered map can cause when one chunk is slow:

from multiprocessing import Pool
from typing import Iterator

def normalize_stream(
    addresses: list[str],
    workers: int = 4,
    chunksize: int = 2_000,
) -> Iterator[NormalizedRecord]:
    """Yield normalized records as soon as any worker finishes (unordered)."""
    with Pool(processes=workers) as pool:
        yield from pool.imap_unordered(
            normalize_record, addresses, chunksize=chunksize
        )

Parameter Breakdown

Parameter Controls Guidance
max_workers / processes Number of worker processes Start at os.cpu_count(); drop to physical cores if hyperthreads thrash
chunksize Records per task shipped to a worker Raise until pickling/IPC is small vs per-record CPU; 1k–5k is common
Start method spawn vs fork fork (Linux) is cheaper but unsafe with threads; spawn is portable and safe
imap_unordered vs map Result ordering Unordered keeps workers busiest; use ordered only if you rely on sequence
Worker function scope Picklability Must be top-level/importable; closures and lambdas fail to pickle
Payload size IPC cost Send small primitives (strings/keys), not large DataFrames or open connections

Chunk Size Decides Whether It Helps

Handing a process pool one address at a time makes it slower than a plain loop, because every task pays serialisation and scheduling overhead measured in tens of microseconds against work measured in single microseconds. Chunking amortises that cost, and there is a broad plateau where the choice barely matters.

Throughput against rows per task A curve of throughput versus chunk size on a log scale. Throughput is very low at one row per task, rises steeply to a plateau between one thousand and fifty thousand rows per task, and declines beyond one hundred thousand as memory pressure and straggler tasks dominate. plateau — pick anything here 1 1 000 50 000 500 000 rows per task — the decline on the right is memory pressure plus stragglers: one slow chunk holds up the whole pool.

The straggler effect on the right deserves a word. With four workers and four chunks, a single slow chunk means three idle workers for its whole duration. Prefer several times more chunks than workers — the scheduler then keeps everyone busy and the tail shrinks to one chunk rather than one quarter of the job.

The pandas np.array_split Variant

For DataFrame pipelines, split the frame into one partition per worker with numpy.array_split, normalize each partition in a worker, and concatenate. This ships a few large partitions rather than millions of tiny tasks, which minimizes pickling overhead:

import numpy as np
import pandas as pd
from multiprocessing import Pool

def _normalize_partition(part: pd.DataFrame) -> pd.DataFrame:
    """Normalize one DataFrame partition inside a worker process."""
    part = part.copy()
    part["canonical_key"] = part["address"].map(
        lambda a: normalize_record(a).canonical_key
    )
    return part


def normalize_dataframe(df: pd.DataFrame, workers: int = 4) -> pd.DataFrame:
    """Parallelize canonical-key generation over a DataFrame.

    Args:
        df: Frame with an 'address' column.
        workers: Worker process count.

    Returns:
        The frame with a 'canonical_key' column, row order preserved.
    """
    partitions = np.array_split(df, workers)
    with Pool(processes=workers) as pool:
        results = pool.map(_normalize_partition, partitions)
    return pd.concat(results, ignore_index=True)

After keying, deduplicate on canonical_key and resolve the cache before dispatching — the ordering discipline covered in optimizing batch geocoding throughput — so the process pool only ever normalizes work the network stage will actually use.

What Crosses the Process Boundary

Everything sent to a worker is pickled, sent, and unpickled — so the boundary is where naive designs lose all their gains. Sending a DataFrame slice per task, or a closure that captures a large lookup table, can cost more than the work itself. Send the minimum and initialise the rest once per worker.

Per-task payload versus per-worker initialisation Two designs. In the expensive design, each task carries a slice of rows plus a captured lookup table, so the table is serialised once per task. In the lean design, the pool initialiser loads the lookup table once per worker and each task carries only a list of strings. expensive each task carries: rows + suffix_table + gazetteer the tables are re-pickled for every single task in the queue serialisation can exceed the work lean initializer= loads tables once per worker each task carries: list[str] module-level globals in the worker hold the compiled patterns and lookups payload shrinks by orders of magnitude Compiled regular expressions belong in the same place: compile at module import, inside the worker, never per call.

Returning data has the same cost in reverse. A worker that returns full rows sends everything back through the pipe; a worker that returns only the parsed components — or writes its own results and returns a count — keeps the return trip small. On a large batch that asymmetry is frequently the difference between a linear speed-up and none at all.

Edge Cases

Pickling Overhead Erases the Gains

Every task argument and return value is pickled, piped to a worker, and unpickled. With a tiny chunksize and cheap per-record work, this serialization dominates and a process pool runs slower than a plain loop. Fix it by raising chunksize (or using np.array_split partitions) so each IPC round trip carries thousands of records, and by returning compact keys rather than fat objects.

Passing Unpicklable State to Workers

Open HTTP clients, database connections, and compiled C handles cannot be pickled and sent to a worker. Do not pass them as arguments. Compile regex at module level (as above, inherited on import) and create per-worker connections lazily inside the worker using a Pool(initializer=...) hook, never by pickling a live handle from the parent.

Reaching for Processes on I/O-Bound Calls

Wrapping the actual geocoding HTTP request in a process pool wastes memory on dozens of idle interpreters that spend all their time blocked on the network. That stage is I/O-bound: use the async client from building async geocoding requests in Python instead, and reserve processes for the CPU-bound normalization that precedes it.

Integration Note

Multiprocessing is one half of the throughput story in optimizing batch geocoding throughput: the process pool absorbs the CPU-bound parse/normalize/key stage, and an async dispatcher absorbs the I/O-bound API calls on the deduplicated misses. The two compose cleanly — normalize and key in parallel, hand the distinct cache misses to the event loop. The canonical-key logic used inside each worker is developed in full in generating canonical address keys in Python.