Writing a Valid OSM PBF File from Python Jump to heading
Reading a PBF forgivingly is easy; writing one that every other reader accepts is where the specification’s quiet requirements surface. Most of them are about size limits and ordering, and none of them produces a helpful error when broken.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Five requirements decide whether a reader accepts your file.
The header must declare what you used. required_features lists capabilities a reader must support — OsmSchema-V0.6 always, DenseNodes if you used them, HistoricalInformation for history files. Declaring a feature you did not use makes readers refuse unnecessarily; omitting one you did use makes them misread silently.
Blobs have hard size limits. An uncompressed blob must not exceed 32 MiB and a header blob 64 KiB, with a strong recommendation to stay under 16 MiB for data. A reader encountering a larger blob is entitled to refuse.
String tables are per block. Each block carries only the strings its own elements use, and the empty string occupies index 0.
Elements must be sorted and grouped. Nodes, then ways, then relations, each ascending by identifier. Many readers rely on this for streaming joins, and a file that violates it works in some tools and fails mysteriously in others.
Dense nodes are delta-encoded. Identifiers, latitudes and longitudes are stored as differences from the previous value, with the first relative to zero.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import struct
import zlib
from dataclasses import dataclass, field
from pathlib import Path
# Generated from the OSM .proto definitions with protoc.
from osmformat_pb2 import (DenseNodes, HeaderBlock, PrimitiveBlock,
PrimitiveGroup, StringTable)
from fileformat_pb2 import Blob, BlobHeader
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.pbf.write")
MAX_BLOB_UNCOMPRESSED = 16 * 1024 * 1024 # recommended ceiling, not the hard 32
MAX_HEADER_BLOB = 64 * 1024
NANO = 1_000_000_000
@dataclass
class Node:
osm_id: int
lat: float
lon: float
tags: dict[str, str] = field(default_factory=dict)
class TableBuilder:
"""Per-block string table. Index 0 is the empty string, always."""
def __init__(self) -> None:
self._index: dict[str, int] = {"": 0}
self._entries: list[str] = [""]
def add(self, text: str) -> int:
existing = self._index.get(text)
if existing is not None:
return existing
self._index[text] = len(self._entries)
self._entries.append(text)
return self._index[text]
def build(self) -> StringTable:
table = StringTable()
table.s.extend(e.encode("utf-8") for e in self._entries)
return table
def write_blob(handle, blob_type: str, payload: bytes) -> None:
"""Frame one blob: length, BlobHeader, then the compressed Blob."""
raw_size = len(payload)
limit = MAX_HEADER_BLOB if blob_type == "OSMHeader" else MAX_BLOB_UNCOMPRESSED
if raw_size > limit:
raise ValueError(f"{blob_type} blob is {raw_size} bytes, over the "
f"{limit} limit; emit smaller blocks")
blob = Blob()
blob.raw_size = raw_size
blob.zlib_data = zlib.compress(payload, 6)
encoded = blob.SerializeToString()
header = BlobHeader()
header.type = blob_type
header.datasize = len(encoded)
header_bytes = header.SerializeToString()
# The only fixed-width field in the whole format: a big-endian int32.
handle.write(struct.pack(">I", len(header_bytes)))
handle.write(header_bytes)
handle.write(encoded)
def write_header(handle, bbox: tuple[float, float, float, float],
dense: bool, source: str) -> None:
block = HeaderBlock()
# Declare exactly what the file uses: over-declaring makes readers refuse.
block.required_features.append("OsmSchema-V0.6")
if dense:
block.required_features.append("DenseNodes")
block.optional_features.append("Sort.Type_then_ID")
block.writingprogram = "osm-pipeline-example 1.0"
block.source = source
left, bottom, right, top = bbox
block.bbox.left = int(left * NANO)
block.bbox.bottom = int(bottom * NANO)
block.bbox.right = int(right * NANO)
block.bbox.top = int(top * NANO)
write_blob(handle, "OSMHeader", block.SerializeToString())
def build_dense_block(nodes: list[Node]) -> PrimitiveBlock:
"""One block of dense nodes, delta-encoded and tag-interleaved."""
table = TableBuilder()
dense = DenseNodes()
last_id = last_lat = last_lon = 0
for node in sorted(nodes, key=lambda n: n.osm_id):
lat = int(node.lat * NANO)
lon = int(node.lon * NANO)
# Deltas against the previous node; the first is against zero.
dense.id.append(node.osm_id - last_id)
dense.lat.append(lat - last_lat)
dense.lon.append(lon - last_lon)
last_id, last_lat, last_lon = node.osm_id, lat, lon
for key, value in node.tags.items():
dense.keys_vals.append(table.add(key))
dense.keys_vals.append(table.add(value))
dense.keys_vals.append(0) # terminator, even when untagged
block = PrimitiveBlock()
block.stringtable.CopyFrom(table.build())
block.granularity = 100 # nanodegrees per unit
block.lat_offset = 0
block.lon_offset = 0
group = PrimitiveGroup()
group.dense.CopyFrom(dense)
block.primitivegroup.append(group)
return block
def batch_by_size(nodes: list[Node], target_bytes: int) -> list[list[Node]]:
"""Batch by ESTIMATED SIZE, not by count: tag-heavy nodes are much larger."""
batches: list[list[Node]] = [[]]
size = 0
for node in nodes:
estimate = 16 + sum(len(k) + len(v) + 4 for k, v in node.tags.items())
if size + estimate > target_bytes and batches[-1]:
batches.append([])
size = 0
batches[-1].append(node)
size += estimate
return batches
def write_pbf(path: Path, nodes: list[Node],
bbox: tuple[float, float, float, float]) -> None:
ordered = sorted(nodes, key=lambda n: n.osm_id)
with path.open("wb") as handle:
write_header(handle, bbox, dense=True, source="osm-pipeline-example")
for batch in batch_by_size(ordered, MAX_BLOB_UNCOMPRESSED // 2):
payload = build_dense_block(batch).SerializeToString()
write_blob(handle, "OSMData", payload)
logger.info("wrote %d node(s) to %s", len(ordered), path)
if __name__ == "__main__":
sample = [Node(1, 50.06, 19.94, {"amenity": "pharmacy"}),
Node(2, 50.07, 19.95, {})]
write_pbf(Path("out.osm.pbf"), sample, (19.9, 50.0, 20.0, 50.1))
Step-by-step walkthrough Jump to heading
- Declare features honestly.
DenseNodesgoes in only when dense nodes are used. A file declaring it without using them is refused by strict readers for no reason. - Check blob sizes before writing. The check is on the uncompressed payload, because that is what the limit applies to and what a reader must allocate.
- Batch by estimated size, not by element count. A thousand untagged nodes and a thousand heavily tagged ones differ by more than an order of magnitude, so a count-based batch overflows unpredictably.
- Build the string table as you go. Adding strings during encoding means the table contains exactly what the block uses and nothing else.
- Emit a terminator for every node. Including untagged ones, whose contribution is the terminator alone. Omitting it desynchronises every reader.
- Delta-encode against the previous value in the group. The first element’s deltas are against zero, and the accumulator resets at each block boundary.
- Sort before batching. Sorting within a batch is not enough; the file-level order must be ascending, so the sort has to happen before the split.
- Write the length prefix big-endian. It is the only fixed-width field in the format and the only place byte order matters.
Verification Jump to heading
- An independent tool reads it.
osmium fileinfoshould report the expected element counts and bounding box. - Round-trip the data. Read the file back with a different library and compare element counts and a sample of tags.
- Blob sizes are within limits. Instrument the writer to log each blob’s uncompressed size; none should approach the ceiling.
- Order holds across blocks. Confirm the last identifier of one block is less than the first of the next.
- Untagged nodes survive. Include some in the test data and confirm they read back with empty tags rather than inheriting a neighbour’s.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Some readers refuse the file | A declared feature is unused | Declare only the features actually used |
| Reader reports an oversized blob | Batched by element count | Batch by estimated uncompressed size |
| Tags attached to the wrong nodes | Terminator omitted for untagged nodes | Append a zero after every node’s tags |
| Coordinates wildly wrong | Deltas taken against the wrong baseline | Reset accumulators at each block boundary |
| Streaming joins fail downstream | Elements not globally sorted | Sort before batching, not within batches |
| File unreadable from the first byte | Length prefix written little-endian | Pack the prefix as a big-endian integer |
| Strings decode as nonsense | String table shared across blocks | Build a fresh table per block |
Specification reference Jump to heading
A PBF file is a sequence of blobs, each preceded by a four-byte big-endian length and a
BlobHeader. Header blobs must not exceed 64 KiB and data blobs must not exceed 32 MiB uncompressed, with 16 MiB recommended. TheHeaderBlocklistsrequired_featuresa reader must support. Dense node identifiers and coordinates are delta-encoded within a group, and the string table’s index 0 is the empty string. See the PBF format documentation for the message definitions and limits.
Frequently Asked Questions Jump to heading
Should I write PBF by hand at all?
Usually not. An existing writer handles every requirement here and has been tested against the readers you care about. Writing by hand is worth it when you need output a library does not produce, when you are learning the format deliberately, or when the dependency is unacceptable. If you do, verify against at least two independent readers, because conformance mistakes are accepted by some tools and not others.
Why batch by size rather than element count?
Because element size varies by more than an order of magnitude. A thousand untagged nodes might be twenty kilobytes; a thousand heavily tagged points of interest might be four hundred. A count-based batch tuned on one produces oversized blobs on the other, and the limit applies to the uncompressed payload, so compression does not rescue it. Estimating size per element and accumulating is a few lines and removes the failure entirely.
What happens if I declare a feature I did not use?
Strict readers refuse the file, because the declaration is a statement that they must support something to read it correctly. Lenient readers accept it. The result is a file that works in whichever tool you tested with and fails elsewhere, which is harder to diagnose than an outright rejection. Declare exactly what the file contains.
Does element ordering really matter?
Yes, for consumers that stream. A reader building way geometry in a single pass depends on encountering all nodes before the ways that reference them, and that only holds if the file is grouped by type and sorted by identifier. A file violating the order reads fine in tools that buffer everything and produces missing geometry in tools that do not — usually reported as a data gap rather than as a format problem.
Related Jump to heading
- PBF File Structure Deep Dive — the parent topic and the container this writes.
- Decoding the PBF String Table and Tag Indices — the table this builds, from the reading side.
- Reading Dense Nodes and Delta-Encoded Coordinates — the encoding this must produce correctly.
- How to Decode OSM PBF Headers in Python — verifying the header this writes.
- Converting OSM XML to PBF with osmium — the tool that does all of this for you.
Up one level: PBF File Structure Deep Dive.