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.
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 |
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.
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.
Related
- Optimizing batch geocoding throughput — the parent guide combining dedup, caching, concurrency, and chunked I/O into one pipeline.
- Building async geocoding requests in Python — the asyncio counterpart for the I/O-bound API dispatch stage.
- Generating canonical address keys in Python — the canonical-key function the workers apply during normalization.