Overview generalization tuning¶
tylertoo overview builds an
overview file
with precomputed generalizations of a GeoParquet dataset. Level 0 is the
coarsest; level L-1 is the finest, or canonical, level. Two kinds of
settings control detail:
- Thinning and visibility decide which features survive.
- Simplification decides how many vertices each survivor keeps.
These factors multiply the level's ground sample distance (GSD): the smallest
ground distance, in meters, that it resolves. --gsd-base or --gsd sets
the GSD ladder. Use Worked scenarios to find settings
for a specific problem. The
CLI reference lists every flag with its default.
Complete tiles or small tiles¶
By default, tylertoo preserves polygon coverage at every zoom, as tippecanoe does. Admin areas, parcels, land cover, and fields draw without holes at coarse and middle zooms. Three defaults work together:
- The density budget thins points only, and polygons are not thinned per cell. Lines and polygons keep every feature that passes the visibility gates.
- A polygon too small to draw at a level becomes a small placeholder square, and the polygons that thinning removes are represented by squares too, so dense areas stay filled.
- When a tile goes over the size cap, its densest lines and polygons are merged into a neighbor instead of being dropped, and the tile is measured again until it fits.
These defaults cost time. A build took about 1.3× as long as with the defaults before #745 on 632k FTW field polygons (z0–z12), and 1.3× on 4.6M, scaling linearly; peak memory, set by the convert step, rose 6% and 23%. Use these settings to balance coverage, build time, and file size:
| Goal | Settings | Cost and effect |
|---|---|---|
| No holes where small polygons are dense (parcels, small admin units) | export-pmtiles --coverage-fill |
Each tile also draws, under its own features, the area its child tiles cover, so placeholder squares no longer leave gaps between them: Auckland parcels at z8 go from 28.5% of the area missing to none. 1.10–1.17× the build time and 4% larger archives on Madagascar admin-4 and NZ parcels. On scattered polygons smaller than a pixel (FTW fields) it also draws area between them. See coverage fill. |
| Fewer slivers between neighbors | --simplify-factor 0.25 |
About tippecanoe's coverage on dense parcels. See coverage and simplification. |
| No slivers at all | --simplify-factor 0, plus --profile bounded when memory is tight |
Neighbors meet without gaps. The overview file grows about 2.7×, and dense small polygons build up to 10× slower. |
| Smaller, faster builds (the output before #745) | --drop-polygons --drop-lines --no-collapse, plus --size-valve drop |
Fastest. A polygon coverage shows holes at coarse and middle zooms. |
| Preserve each retained feature's attributes | --size-valve drop |
An oversized tile drops features instead of merging them, so it shows holes, but no feature takes on the geometry of another. |
Coverage and simplification¶
Each polygon is simplified on its own, so neighbors that share a border can end up with slightly different edges: thin gaps (slivers) open along shared borders. At the default tolerance they are mostly narrower than a pixel at the zoom they are drawn at, and visible only as faint lines in a solid fill of dense polygons. Tippecanoe does the same by default, at a finer tolerance (1 tile unit, about a quarter of tylertoo's), so its slivers are smaller.
Use --simplify-factor to balance coverage and resource use. The following
measurements use about 400,000 NZ parcels around Auckland (z0–z14). Area
drawn is the share of source area covered on the three densest z12 tiles:
--simplify-factor |
Area drawn | Build time | Peak memory | Archive |
|---|---|---|---|---|
| 2 | 87.4% | 10.8 s | 2.6 GiB | 132 MB |
| 1 (default) | 97.6% | 13.8 s | 3.2 GiB | 145 MB |
| 0.5 | 98.9% | 17.3 s | 4.0 GiB | 150 MB |
| 0.25 | 99.5% | 22.4 s | 4.5 GiB | 155 MB |
| 0.1 | 99.6% | 21.9 s | 4.5 GiB | 157 MB |
| 0 (no simplification) | 99.99% | 17.1 s | 3.2 GiB | 155 MB |
Tippecanoe drew 99.5–99.6% of the same tiles. Coverage flattens below
about 0.25, at 99.6–99.7%. Smaller nonzero factors increase time and memory
use with little improvement in coverage. With
--simplify-factor 0 only the tile grid reduces vertices, and it snaps a
shared border identically on both sides, so no slivers form. Export then
costs about the same as at the default, but every level of the overview
file stores full-resolution geometry: on all 4.3M NZ parcels the overview
file grew from 2.9 GB to 7.9 GB, and the convert step used 19 GiB under the
default memory profile, or 4.9 GiB with --profile bounded (which took
38 s instead of 15 s). On dense small fields it costs far more: FTW Moldova
(632k fields) took 94 s against 9 s at the default, with 2.7× the memory.
These numbers are for z12, where parcels are still drawn as shapes. At coarser zooms most polygons are smaller than a pixel and are drawn as placeholder squares, whose size is independent of the factor. A smaller factor still helps where polygons stay drawable (Moldova at z5: 22% of the field area missing at the default, 13% at 0.25, 12% at 0), but in dense blocks the squares themselves overlap and leave gaps regardless of the factor (#750).
What merging does to attributes¶
Merging follows tippecanoe's --coalesce-densest-as-needed. The merged
feature keeps the attributes and id of the feature it was merged into. The
absorbed features' geometry is drawn as part of it, and their attributes are
discarded. In a choropleth, an absorbed polygon therefore takes its
neighbor's color, and the merged areas can end at tile edges, where the
next tile merged differently. Use --size-valve drop when every drawn
polygon must retain its own attributes.
Merging works in the input's row order: each feature is merged into the
previous retained feature. First, sort the input spatially with
gpio sort hilbert so consecutive features are near each other. On shuffled
input, merged features can contain unrelated geometry, and color changes at
tile edges become far more frequent.
Coverage fill: --coverage-fill¶
Placeholder squares carry the right total area, but they sit where the small
polygons were, so in a dense block they overlap one another and leave gaps
between them (#750). export-pmtiles --coverage-fill closes those gaps.
Before writing any tile, the export reads the finest level once and works out,
for every coarser tile, the area its child tiles cover. Each tile then draws
that area under its own features.
- Touching shapes in the fill are merged into one and simplified at 4 tile
units (one GSD at the default
--gsd-baseand extent), so at coarse zooms the fill reads as dissolved shapes, not individual polygons. Area lost to the simplification comes back as small grid-aligned squares near where it was lost. - Each piece of the fill takes the id and attributes of one feature it sits under, as a merged feature does. In a dense coverage one dissolved piece can span many features, so a style that colors by attribute draws that piece in one feature's color, and a style that draws one class above another can draw it over the other class.
- The fill only takes the room the tile's own features leave under the size cap, and at most about a quarter of it, dropping its smallest pieces first. It never makes the size valve merge or drop a real feature. So the fill needs headroom: under tippecanoe's 500K compressed cap, Madagascar admin-4 at z4 misses 0.01% of the area z8 draws, tile by tile; under a 500K raw cap the z4 tiles are nearly full, and it misses 8.6% (12.8% without the fill).
- The fill is built from the finest level, so in tiles that have features
of their own it also draws the area of polygons that
--drop-polygonsor a dot-fill recipe removed from coarse levels. A tile left with no features at all is not written, fill or not (#766). - The export holds the finest level's geometry in memory while it builds
the fill, outside the RAM budget the export plans for: about 1.7 GB more
on 4.3M NZ parcels, growing with the input (#764). Its working space
comes out of that budget: when the largest fill tile cannot fit what is
left, the export stops with
coverage fill needs N bytes.TYLERTOO_SKIP_MEMORY_PREFLIGHTdoes not cover this check; drop--coverage-fill, use a bigger machine, or reduce the input. - In a sharded build the coarse job (
tiles --shard coarse, orexport-pmtiles --zoom-ceiling) builds the fill from the finest level and draws it at the zooms it owns, the same fill a single-job build draws there; to do that,tiles --shard coarse --coverage-fillbuilds every level of its intermediate instead of stopping at the pivot. Data shards (--tile-range) own the zooms from the pivot down, where the fill matters least, and skip it (#771).
On the benchmark inputs, the fill adds 17% to end-to-end time on NZ parcels and 10% on Madagascar admin-4, and 4% to the archive. On FTW Moldova, where fields are smaller than a pixel at coarse zooms, it halves the missing area but draws more area between fields, and the build takes about 1.5× as long (#762).
The GSD ladder: --gsd-base and --gsd¶
A zoom-range plan (--min-zoom and --max-zoom, the default) derives each
level's GSD from its Web Mercator zoom:
gsd(z) = 40075016.69 / gsd_base / 2^z (meters, spec §5.2)
| Knob | Default | Units | Effect |
|---|---|---|---|
--gsd-base F |
1024.0 |
dimensionless | base of the formula above |
--gsd G1,G2,… |
none | meters, strictly decreasing | explicit per-level GSDs that override the zoom range and --gsd-base |
--min-zoom / --max-zoom |
0 / 6 (0 / 14 on tiles) |
Web Mercator zoom | coarsest and finest (canonical) level, at most 30 |
--gsd-base scales the whole zoom-range ladder:
- A larger value gives smaller GSDs: denser, more detailed, larger levels.
- A smaller value gives larger GSDs: sparser, less detailed, smaller levels.
The default 1024 follows the cogp-rs convention: about 4× a 256 px tile, so
sub-pixel features drop (spec §5.2, Q6). --gsd already gives absolute
meters, so --gsd-base has no effect alongside it.
The thinning, visibility, and simplification factors all multiply GSD.
Adjust them individually for one geometry class; adjust --gsd-base when
the whole map is too sparse or too dense.
The geo:overviews provenance in the footer records a non-default base as
generalization.gsd_base. A default run omits it, because the
levels[].gsd values in the footer already imply 1024.
--max-zoom auto¶
--max-zoom auto estimates the finest zoom from the input's own features, as
tippecanoe's -zg does. tylertoo reads the bounding boxes of a deterministic
sample of at most 200,000 rows, honoring --bbox and --filter. On
gpio-optimized input the sample reads only the f64 bbox covering column, never
the geometry bytes, which makes the estimate near-free — on remote input
especially (#693). Without a declared, all-f64 covering it reads the geometry
column instead; an f32 covering, an undeclared one, or a covering whose
values turn out to be unusable all take the geometry path. The covering is
taken on trust, so a stale one — a file whose reprojection left the bbox
struct untouched — moves the pick.
The estimate reads the same input the conversion then reads. On remote input it fetches the sampled column's chunks concurrently and keeps them in the disk spill, so the conversion does not fetch them again.
It picks the zoom where one pixel of a 256 px tile resolves about half the smaller of two measures, in Web Mercator meters:
- the median size of the features that have an extent
- the tenth-percentile spacing between nearby features
The result is clamped to [--min-zoom, 16] and logged with its measurements
at info. Treat it as a starting point. From Python, the line reaches
logging at INFO under the tylertoo_core logger, and the finest zoom
in the report's levels gives the chosen zoom.
An input with nothing to measure, such as one location or only empty
geometries, is an error, as with -zg. So is a --min-zoom above 16. With
--gsd, tylertoo ignores the value and estimates nothing. The divergence
table in context/ARCHITECTURE.md compares the formula with tippecanoe's.
The zoom ceiling¶
tylertoo rejects any zoom above 30 at option validation, before it opens the
input. The limit marks what tile arithmetic can address, not what is useful.
Tile coordinates are 32-bit, so z31 is the hard limit, and the PMTiles
Hilbert tile id needs 4^z of headroom in a u64. Thirty leaves one level of
margin. The same ceiling applies to --min-zoom and to a --gsd ladder fine
enough to imply a zoom above 30.
Cost grows about 4× per zoom: z30 holds about 1.15e18 tiles at a GSD of
about 3.6e-5 m. A point touches one tile per zoom, so point data stays cheap.
Lines and polygons do not: one 1-degree linestring spans on the order of 3e6
tiles at z30. For non-point data, raise --max-zoom one level at a time and
watch the output size.
Several inputs, one archive: tylertoo pyramid¶
A pyramid combines inputs in one archive, typically summaries at coarse zooms and raw features at fine zooms. The client switches between them without a second request.
tylertoo pyramid fire-2023.pmtiles \
--band "0-5:cells_r5.parquet:aggregate" \
--band "6-8:cells_r8.parquet:aggregate" \
--band "9-14:points.parquet:features"
| Layer | Source | Zooms |
|---|---|---|
aggregate |
r5 cells | z0–5 |
aggregate |
r8 cells | z6–8 |
features |
raw detections | z9–14 |
Bands may share a layer name: aggregate changes source at z6 here. Bands
in the same layer must have disjoint zoom ranges, or they would write the
same tile ids. tylertoo checks this before conversion.
Bands in different layers may share zooms, as with tippecanoe's -L.
Shared tiles combine the layers; tiles from only one band pass through
untouched. For example,
--band 0-13:a.parquet:2024 --band 0-13:b.parquet:2025 builds one archive
with two independent layers over the same zooms.
Bands tile verbatim by default to preserve their input resolution. Pass
--generalize for raw features that need generalization across several zooms.
Band sources and syntax¶
A band accepts GeoParquet or PMTiles via --band LO-HI:PATH[:LAYER].
tylertoo detects the format from the contents, tiles GeoParquet over the
band's range, and merges an archive as-is. Mix formats to reuse a coarse
archive while rebuilding a fine band.
A GeoParquet band may be remote:
--band 9-13:https://data.source.coop/…/features.parquet streams with
byte-range requests, as tiles and overview do. Archive bands must be
local for directory and tile reads by offset. Stage remote .pmtiles bands
before use.
LAYER defaults to the input's file stem, or the
directory name for a glob or a directory. A path may contain colons
(s3://…, https://…, C:\…). tylertoo splits the layer off the last
colon only when the text after it has no /, \, or :. A URL scheme, a
port, and a 2024:06/ directory therefore stay part of the input. A
drive-relative path such as C:data.parquet also stays whole: a single ASCII
letter before the last colon counts as a drive letter.
For an input ending in a bare colon segment, such as the Hive directory
admin:country_code=BR, use --band LO-HI=INPUT[=LAYER].
tylertoo splits the range at
the first = and the layer at the last. As before, it splits only when the
text after it has no /, \, or :. That keeps --band 0-13=admin:country_code=BR/part.parquet
whole. An input that itself ends in =VALUE reads as a layer, so give it an
explicit =LAYER.
For a pre-tiled archive the layer is a label. The layer name inside its tiles is whatever its export wrote. When two bands share zooms, those names must agree. Otherwise the combined tile holds two layers of one name, and a client drops one.
Gaps and partial archives¶
A gap between bands warns: the merged archive
advertises one continuous range, so a client requests the uncovered zooms and
gets nothing. An archive may hold more zooms than its band declares. One
archive splits across bands that way, for example two bands on the same
z0–13 archive declaring 0-5 and 6-13. An archive that holds fewer zooms
than its band declares is an error, because those zooms would render empty.
Check that the band range matches the archive, or pass
--allow-missing-zooms for a deliberately sparse pyramid. A band whose range
shares no zoom with its archive is always an error.
Export settings and disk use¶
pyramid takes the export flags tiles and export-pmtiles take
(--max-tile-size, --max-compressed-tile-size, --size-valve,
--tile-buffer, --feature-order, --feature-id, the property selection,
and the rest), and applies them to every GeoParquet band alike, as
--generalize does. Both size caps are off by default for pyramid bands,
preserving every cell. When set, a cap applies to each band's tiles before
tylertoo combines them. Every GeoParquet band needs the --feature-id
column. A band that lacks the column exports in input order with a warning
naming its layer. An archive band keeps the order it had. tylertoo writes
bands coarsest first, whatever order you list them in.
Intermediates go to --work-dir (the system temp directory by default) and
are removed after success or failure. Budget roughly levels × input size
for each band's duplicating-mode overview. All finished band archives remain
on disk until the merge, which also needs temp space for its spool.
Switching the ladder off entirely: --verbatim¶
Generalization works for features such as roads or buildings that need less detail at coarse zooms. For cell aggregates or prebuilt levels, it can discard data the input already summarizes. An H3 r6 cell holds the sum of its r7 children; dropping cells changes the totals:
$ tylertoo tiles cells.parquet out.pmtiles --min-zoom 0 --max-zoom 5
WARN omitting 1 empty level(s) [0] … none of the 3399 input feature(s)
are visible at those scales (visibility gates / density budget)
z2 (level 0): 757 features ← 3,399 cells in, 757 drawn
z3 (level 1): 1446 features
--verbatim turns the whole ladder off, so every level reproduces the input:
$ tylertoo tiles cells.parquet out.pmtiles --min-zoom 0 --max-zoom 5 --verbatim -f
z0 (level 0): 3399 features
z1 (level 1): 3399 features
z2 (level 2): 3399 features
…
Its output is byte-identical to setting eight ladder knobs by hand:
--no-density-drop --no-coalesce-lines --simplify-factor 0 \
--point-thinning 0 --line-thinning 0 --polygon-thinning 0 \
--polygon-visibility 0 --line-visibility 0
Use --verbatim for:
- A discrete global grid system (DGGS) or other cell aggregate.
- Levels already built upstream.
- One band of a pyramid whose coarser zooms come from another input.
It leaves the mode, the level plan, the coordinate reference system (CRS),
the row-group layout, an explicit --cluster, and --representation alone.
A requested point or square band still replaces polygons with centroids or
placeholder squares.
Explicit settings override --verbatim defaults. For example,
--verbatim --simplify-factor 0.5 keeps every feature but simplifies its
geometry. The report identifies the override.
It requires --mode duplicating. Partitioning writes each feature at exactly
one level. With thinning off, every feature would land in the coarsest level
and every finer level would be empty, so tylertoo rejects the combination.
On tiles, --verbatim also disables the per-tile size caps. An explicit
--max-tile-size or --max-compressed-tile-size restores that cap.
For two-step builds, tylertoo export-pmtiles still applies its default
caps (500K compressed, 2M raw) after tylertoo overview --verbatim. Pass
--max-compressed-tile-size 0 --tile-size-limit 0 to export every feature.
0 is the off switch¶
Every thinning factor accepts 0, meaning no thinning: each feature is
its own grid cell, so every feature that passes the gates survives. Negative
and non-finite factors are errors.
Feature thinning: --point/line/polygon-thinning¶
Thinning keeps one winning feature per grid cell per level. The ranking picks the winner. The cell size for each geometry kind is:
cell_size = thinning_factor * gsd(level) (in the CRS's units)
| Knob | Default | Units | Direction |
|---|---|---|---|
--point-thinning |
4.0 (16.0 with --cluster) |
× GSD (cell size) | bigger = sparser |
--line-thinning |
1.0 |
× GSD (cell size) | bigger = sparser |
--polygon-thinning |
0 (off) |
× GSD (cell size) | bigger = sparser |
Larger factors give larger cells and fewer survivors. Lower
--line-thinning if coarse roads look too sparse.
Points use 4.0 because they clutter sooner than lines, which use 1.0. In
the Portland roads sweep, 1.0 kept roads more continuous at coarse zooms
with little loss of legibility. Polygon thinning has been off since #745. A cell
keeps one polygon by bounding-box center. A polygon whose center falls in a
larger neighbor's cell, such as a parcel inside a ring-shaped one, was removed from
every coarse level and left a hole. The visibility gate and
placeholder squares
already handle polygons too small to draw. Disabling polygon thinning had no
measurable performance cost on NZ Parcels
(corpus/SWEEPS.md).
The factor multiplies the GSD, so it stacks with --gsd-base. Doubling
--gsd-base halves the GSD, which halves the cell size for the same factor.
Visibility gates: --line/polygon-visibility¶
A line or polygon is eligible only if its bounding-box diagonal clears the level's gate:
eligible ⇔ bbox_diagonal >= visibility_factor * gsd(level)
Features below the gate reappear at finer levels as GSD shrinks. Points have no gate.
| Knob | Default | Units | Direction |
|---|---|---|---|
--line-visibility |
2.0 |
× GSD (min bbox diagonal) | bigger = sparser |
--polygon-visibility |
2.0 |
× GSD (min bbox diagonal) | bigger = sparser |
A bigger factor raises the bar and drops more small features at coarse levels. A smaller factor keeps more of them.
The polygon default matches the write stage's effective survival threshold
of about 2 × GSD: polygons also drop when their simplified geometry falls
below --simplify-factor × GSD. Stricter gates remove otherwise drawable
features. On Germany buildings,
2.0 instead of 4.0 gives 2.7–4.6× more features at z8–z12 and starts the
pyramid one zoom coarser, for 13% more file size and 7% more conversion time
(corpus/SWEEPS.md,
Decision 6). Values below 2.0 change little on their own, because
simplification collapses nearly every extra feature at write time. The
exception is --collapse, which keeps them as representative points, as in
the dot-fill recipe.
The gate scales with zoom and --gsd-base. Going one zoom coarser doubles
the threshold. A feature can fail it even in an empty thinning cell.
Empty coarse levels: the auto-clamp¶
When every feature fails a coarse level's gates, tylertoo omits the level
and renumbers the rest (spec §7.3). For buildings at --min-zoom 0, the
threshold 2 × gsd(z0) is about 78 km. A typical warning is:
omitting 6 empty level(s) [0, 1, 2, 3, 4, 5] spanning GSD 39135.76–1222.99 m:
none of the 59032924 input feature(s) are visible at those scales (visibility
gates / density budget); the output pyramid starts at GSD 611.50 m (zoom 6).
To populate coarse levels, lower --polygon-visibility/--line-visibility, or
pass --collapse to keep sub-GSD polygons as representative points (see
docs/OVERVIEW_TUNING.md)
A note: line also appears under the CLI level table. The report's
skipped_empty_levels lists each omitted level's planned index, GSD, and
zoom, and the written levels array is the effective range. The same clamp
applies when a level empties during simplification: a feature's bbox can
clear the gate while its geometry collapses below the level tolerance.
PMTiles export of a clamped file starts at the coarsest written level's zoom.
The omitted levels contain no drawable features. To populate them, lower
the gates, raise --gsd-base, or use the
dot-fill recipe
(--polygon-visibility 0 --collapse). If no level has any rows, as with
an empty input or an empty --bbox selection, the conversion fails.
Per-feature simplification: --simplify-factor¶
Simplification runs Ramer–Douglas–Peucker (RDP) on each surviving feature's geometry with a world-space tolerance:
tolerance = simplify_factor * gsd(level) (meters, then CRS-converted)
| Option | Default | Effect |
|---|---|---|
--simplify-factor |
1.0 |
RDP tolerance in GSD multiples; larger = less detail |
--collapse |
off | Keep below-tolerance polygons as points |
--collapse-square |
off | Keep placeholder squares of about 1 × GSD (details) |
--representation LO-HI:KIND,… |
all geom |
Choose geom, point, or square by zoom band |
--no-cascade |
off | Disable cascading simplification |
Lower --simplify-factor to keep more vertices; raise it for less detail
and smaller levels.
Simplification applies in duplicating mode only. The canonical level always
stays verbatim, whatever the factor (spec §2.4). --simplify-factor 0
disables simplification.
High factors also remove features whose bbox diagonal falls below the tolerance. Keep the factor modest to shed vertices, and control feature counts with thinning and visibility.
Cascading simplification (default on): --no-cascade¶
In duplicating mode, each coarser level simplifies the next-finer level's output, as tippecanoe does. This avoids repeatedly simplifying canonical geometry, which dominates conversion time without cascading. A single boolean-overlay pass repairs self-intersecting RDP candidates into valid even-odd geometry.
Coarse-level coordinates differ slightly from non-cascaded output. Cascaded
vertices are still a subset of canonical vertices, and every step's output is
validity-checked or repaired. The geometric GSD ladder bounds the cumulative
deviation at about 2× the target level's tolerance instead of 1×. The footer
provenance records generalization.cascade: true. Pass --no-cascade to
reproduce non-cascaded output byte-for-byte.
Validity-check vertex cap¶
A simplification candidate with more than 2,048
total vertices skips the exact validity check and counts as valid. The check
is O(V²) in ring size, and on continental-scale rings at fine GSDs it stalled
conversion for tens of minutes per feature. A candidate that large means RDP
removed few vertices from already-valid input, the case least likely to
self-intersect. The overviews spec does not require valid geometry (§2).
Candidates at or below the cap get the full check and repair. tylertoo logs a
count of skipped checks at info when conversion ends.
Country-scale dot fill for dense polygon layers¶
A dense layer of small buildings, parcels, or fields can render an empty country view with the default settings. Sub-GSD polygons drop in two stages:
- At assignment, a polygon below
--polygon-visibility × GSDis ineligible. - At write time, an eligible polygon drops if its simplified geometry falls below the tolerance. At z4 that is about 2.4 km, larger than any building.
A 20 m building cannot render as a polygon at z0–z8. --collapse (opt-in,
spec Q4) keeps below-tolerance polygons as representative points. With a
zero visibility gate, coarse levels become a dot field bounded by thinning
and the density budget:
tylertoo overview buildings.parquet buildings_overview.parquet \
--min-zoom 0 --max-zoom 14 \
--polygon-visibility 0 --collapse
# One-shot PMTiles build; the default caps are 500K compressed and 2M raw
# (0 disables either).
tylertoo tiles buildings.parquet buildings.pmtiles \
--min-zoom 0 --max-zoom 14 \
--polygon-visibility 0 --collapse --max-compressed-tile-size 500K \
--profile bounded
For country-scale builds:
- Keep the tile cap. Uncapped dot tiles can reach several MB (Germany z6: 12 MB). The cap thins dots with a spatially even stride to about 500 KB compressed.
- Use
--profile autoto spill when needed, orboundedto force spilling. Coarse levels buffer more rows; forcingspeedraised Germany's peak resident set size from 15 to 29 GB.
On Overture Germany buildings (59M footprints, z0–14), every level populates:
z0 holds 581 dots, z4 128,886, and z6 1,074,540
(corpus/SWEEPS.md,
Decision 6). Levels z6–z13 land on the
density budget ladder
N / 1.65^(14−z), with dense areas such as the Ruhr and Berlin visibly denser
and rural areas protected. The overview file grows 31% (11.9 → 15.6 GB), and
conversion takes 40% longer. On the Moldova field corpus the same recipe adds
0.3% to file size.
Add a small circle layer filtered to ["==", "$type", "Point"]; fill
layers ignore points. The output's geometry_types lists the union, for
example ["Point","Polygon"] (spec §7.5). Collapse is opt-in because it
changes geometry types (spec Q4), and is recorded in generalization
provenance.
Keep the default --drop-rate and --drop-gamma. The budget already caps
coarse levels and shapes dot density. Using --drop-rate 1.3 increased
mid-zoom row counts by 13% without changing the coarse fill.
Zoom-band representation and placeholder squares¶
Use --representation to choose geometry by zoom band, or
--collapse-square to retain polygons as placeholder squares.
--representation LO-HI:KIND,…: the band selector¶
One build can use different representations across zoom bands:
# Dots at coarse zooms, polygons at fine zooms:
tylertoo tiles buildings.parquet buildings.pmtiles -f \
--min-zoom 0 --max-zoom 14 \
--representation "0-7:point,8-14:geom"
# Tippecanoe-style placeholder squares at coarse zooms instead:
tylertoo tiles buildings.parquet buildings.pmtiles -f \
--min-zoom 0 --max-zoom 14 \
--representation "0-7:square"
The supported kinds are:
point: every polygon becomes its representative point. This is the centroid, falling back to the bbox center and then the first vertex for degenerate rings. Polygons bypass the visibility gate and use the point grid (--point-thinning). Render them with acirclelayer.square: normal simplification, with below-tolerance polygons replaced by area-dithered squares of about 1 × GSD (see--collapse-squarebelow). Visible polygons stay unchanged, and fill styles work throughout. Polygons bypass the visibility gate but keep the polygon thinning grid.geom: normal geometry, also the default for unlisted zooms. Below-tolerance polygons follow the global disposition: drop,--collapse, or--collapse-square.
tylertoo validates the bands when conversion starts:
- They need duplicating mode with a zoom-range plan. A
--gsdplan has no zooms to band on. - Bands must not overlap.
- A non-
geomband must end before--max-zoom, because the canonical level is always verbatim (spec §2.4). pointbands must run contiguously from the coarsest planned zoom. The cascade carries the point through every coarser level, so tylertoo rejects a request for polygons coarser than a point band.
With cascading on, a feature entering a point band collapses at the band's finest level, and every coarser band level reuses that point. Lines and native points ignore every band kind. Bands work with clustering and coalescing, and the density budget caps band levels like any other level.
The footer provenance records the bands, as
generalization.representation: [{"zooms": [0,7], "repr": "point"}, …].
--collapse-square: tippecanoe tiny-polygon squares as the global disposition¶
--collapse-square replaces polygons too small for a level with squares
one GSD (one pixel) on a side, converted to CRS units, regardless of
--simplify-factor.
Like tippecanoe's tiny-polygon reduction, it keeps dense fields, buildings,
and parcels visible at coarse zooms with existing fill styles.
geometry_types stays ["Polygon"], so no spec-Q4 geometry-type opt-in
applies.
Placeholder squares are the default since #745. --no-collapse restores the
earlier default, which drops below-tolerance polygons. Under an
entry-zoom ladder,
the default switches to points, because the square's area dithering would
delete the small features the ladder promotes; pass --collapse-square to
use squares instead. The footer provenance records squares as
generalization.collapse: "square", and --collapse records "point".
Two mechanisms share the threshold T = side², where side is one GSD,
floored at one tile unit of the level's zoom (see the divergences below).
Area accumulation and dithering¶
The accumulator covers polygons removed by the visibility gate, thinning,
or density budget. After assignment, each adds its area, clamped to T, to
its patch's running total in input order. A
patch is a 32 × GSD square, 1/32 of a 1024-px tile. Each time a patch's total
crosses T, the polygon that crossed it becomes the level's carrier.
tylertoo emits it as a T-area square at its representative point, with its
own attributes.
A polygon contributes at most one placeholder's area. Gate failures may be
larger than T because the gate is --polygon-visibility pixels wide;
thinning and budget losers can be any size. Each patch retains less than
T in unemitted area. This keeps 25 m fields visible as farmland at z0.
On a 368k-field sample, z1–z6
carry about 98% of the input area, against 1.5–7% with the dither alone. An
entry-zoom ladder (--entry-zoom) decides where its features first appear,
so the accumulator never counts them.
The dither covers polygons the level carries but that RDP shrinks
below T at write time. A polygon of area A survives as a square with
probability A / T, decided by a hash of its anchor coordinates. The two sets
are disjoint, so nothing counts twice.
Both mechanisms are deterministic. The accumulator traverses the pass-1 table in input order; the dither is a pure function of each feature. Output is byte-identical across runs, engines (in-memory, streaming, pipelined), and thread counts. With cascading, a square's anchor is its center. Coarser levels reuse the hash draw with a smaller keep probability, so a square removed at one level cannot reappear at a coarser one.
Differences from tippecanoe¶
See also context/ARCHITECTURE.md.
- Per patch, not per tile. Tippecanoe accumulates per tile. tylertoo accumulates per 32 × GSD patch, because overview levels have no tile scope. Squares land at the carriers' own positions inside the patch. A prepared file is in Hilbert order, so they cluster where the fields are.
- Clamped, not skipped. Tippecanoe accumulates only rings with area
≤
tiny_polygon_size²and keeps larger rings as geometry. tylertoo clamps each polygon's contribution to one placeholder instead: the gate or thinning already dropped a non-member here, so there is no geometry to keep. - Placement. Tippecanoe places the placeholder at the ring's first vertex
with side
tiny_polygon_size(default 2 px). tylertoo places it at the polygon's representative point with side one GSD. - A floor of one tile unit. Tippecanoe's placeholder side is a fixed count
of tile units. tylertoo's is one GSD, floored at one tile unit at the
level's zoom. One unit is
40,075,016.69 m / 2^zoom / 4096, which for a--min-zoom/--max-zoomplan isGSD × gsd-base / 4096. At the default--gsd-base 1024that is a quarter of the GSD, so the floor does not affect the side length. At--gsd-base 8192the floor increases the side to 2 × GSD. The tile encoder keeps a square of sides < 1unit only with probabilitys². The floor lets the encoder draw every placeholder, reducing their count. The log names affected zooms. On those levels a patch's leftover area, under oneT, becomes one more carrier with probabilityleftover / Tinstead of vanishing. The unit assumes the default extent of 4096, the only one the CLI exports at. A larger Pythonexport(extent=…)draws floored squares over more than one unit, and a smaller one draws them under a unit, where they thin as described above. - Independent of
--simplify-factor. As in tippecanoe, the placeholder's size is independent of the simplification setting. Before #745 it wassimplify-factor × GSD: at 0.25 a smaller square represented a quarter of the area, and at 0 there was no square at all, so lowering the factor to close slivers reduced coverage in dense areas at middle zooms. - Duplicating mode only. A carrier is a second appearance of a feature,
which partitioning's feature-once contract cannot represent. In
partitioning mode the levels are verbatim, so tylertoo accepts
--collapse-squareand logs it as inert.
Dense squares at mid zooms can push a tile over its size cap; the
merge valve
then merges them into their neighbors.
tylertoo stats
reports sizes by zoom.
Attribute-driven entry zoom: --magnitude-ladder, --entry-zoom¶
Ranking runs after the visibility gate,
so --sort-key cannot rescue small features the gate removed. For a
population-density choropleth, this can hide tiny urban tracts with high
values while keeping large rural tracts with low values.
An entry-zoom ladder assigns each distinct column value a zoom. Its features appear from that zoom inward, exempt from visibility gates and thinning, and never appear at coarser zooms.
# Derive it: distinct values ranked descending, one zoom apart from --min-zoom
tylertoo tiles in.parquet out.pmtiles --min-zoom 0 --max-zoom 6 \
--magnitude-ladder density
# ...or place the rungs by hand. Rungs must fall inside the zoom range, and
# each names a value the column actually carries.
tylertoo tiles in.parquet out.pmtiles --min-zoom 0 --max-zoom 8 -f \
--entry-zoom "density:5000=0,1000=3,200=6"
With five distinct values, --min-zoom 0 --max-zoom 6, and the default step,
the highest rank appears at z0; each lower rank joins one zoom later. The
ladder uses distinct-value ranks (SQL DENSE_RANK) to avoid concentrating
entry zooms in a narrow range when raw values cluster together.
A column may have more distinct values than the zoom range has room for. Ranks that would fall past the finest zoom get no rung. Their features take the ordinary gate and thinning, and tylertoo does not pin them to the finest level. The run reports how many it left out.
--ladder-step N (default 1) sets the spacing. Nulls and values omitted
from an explicit spec get no rung; their features use ordinary gates and
thinning.
The overview footer records the ladder the run resolved in
generalization.entry_zoom (spec §3.5). The block lists each rung's value
and entry zoom, up to 64 rungs. An explicit rung that matches no value in the
column does not appear. A ladder that placed no feature leaves no block.
Two mechanisms can still undo a ladder¶
Two later stages can remove admitted features:
- Simplification can collapse small geometry below tolerance.
--magnitude-ladderand--entry-zoomtherefore imply--collapseto retain representative points.--collapse-squareoverrides this. - The density budget's size fallback can discard small, high-value survivors. Pair the
ladder with
--no-density-drop, or with--sort-keyon the same column so the budget ranks the way the ladder does. tylertoo warns when a ladder runs with the budget on and no sort key.
With the budget off, each rank appears at its entry zoom and stays at finer zooms:
zoom rank 4 rank 3 rank 2 rank 1 rank 0 (0 = highest value)
z0 0 0 0 0 140
z1 0 0 0 140 140
z2 0 0 165 165 165
z3 0 192 176 176 176
z4 250 192 176 160 160
Relation to tippecanoe¶
The ladder corresponds to tippecanoe's per-feature tippecanoe.minzoom
attribute. --magnitude-ladder also derives entry zooms from a column.
Ranking: which feature wins a cell¶
The highest-priority feature wins each grid cell. Priority uses these tiers, highest first (spec §3.5, Q1):
--sort-key COL: a numeric column, such as population or importance.--class-rank COL:VAL=RANK,…: an explicit categorical map, such asroad_class:motorway=5,primary=4,residential=2. Unlisted values rank below every listed one but above nulls.- Automatic detection, unless you pass
--no-auto-rank. Overture roads (classorroad_class) get a built-in motorway-to-service ranking, and Overture places rank byconfidence. - Size fallback: the larger bbox diagonal wins, and a deterministic hash breaks ties, as in tippecanoe.
Ranking selects survivors; thinning sets their count. For roads, ranking
keeps highways visible at coarse zooms. The
generalization.ranking provenance in the footer records the tier used.
--sort-key and --class-rank are mutually exclusive.
Nulls, NaNs, and infinities rank below every real key. Their rows remain
eligible but lose cells contested by keyed features. The entry-zoom ladder
uses the same rule: non-finite values get no rung. --accumulate-attribute
skips NaNs but includes infinities (see
Clustering).
Density budget: --drop-rate, --drop-gamma, --no-density-drop¶
Once thinning cells are smaller than typical feature spacing, almost every feature wins a cell. Counts plateau near the input count, often from z9 up. On Portland roads, this is 2–3× tippecanoe's count at z9–z11 and accounts for much of duplicating mode's storage overhead.
The density budget caps survivors after thinning, decreasing geometrically toward coarse levels. It drops the lowest-priority features in ranking order:
budget(level) = N / drop_rate ^ (finest_level − level) (N = input features)
keep(level) = min(cell_winner_survivors(level), budget(level))
The canonical level keeps everything (spec §2.4). Levels below their budget stay unchanged, typically including coarse zooms where thinning already limits counts.
| Knob | Default | Units | Direction |
|---|---|---|---|
--drop-rate F |
1.65 |
ratio (>1) | bigger = sparser mid zooms |
--drop-gamma F |
1.5 |
exponent (≥1) | bigger = more sparse-area protection |
--no-density-drop |
off | flag | disables the budget |
--drop-lines |
off | flag | applies the budget to lines too |
--drop-polygons |
off | flag | applies the budget to polygons too |
The budget thins points only by default, as tippecanoe's -r does: removing
a point leaves no hole, while removing a polygon from a coverage does. N
then counts only the features the budget thins. --drop-lines and
--drop-polygons apply the budget to lines and polygons, as tippecanoe's flags of
the same names do; together they restore the budget before #745.
Drop rate¶
Each coarser level has 1/rate of the next finer level's budget. Raise
--drop-rate for sparser mid zooms and smaller files. The default 1.65
comes from Portland roads. It brings z9 to 1.21× and z10 to 1.03× tippecanoe's
counts and puts z11 at 0.67×. It leaves z8 and the coarse zooms near their
cell-winner counts
(corpus/SWEEPS.md).
tylertoo anchors its budget on the full canonical count N; tippecanoe's
-rate uses a per-tile basezoom count. Equivalent thinning therefore needs
a smaller rate here. At 2.5, Portland's z9–z13 counts fall below
tippecanoe's.
Spatial fairness¶
--drop-gamma protects sparse areas by distributing the budget across
128 × GSD super-cells. Each keeps its highest-priority features up to an
allocation ∝ population^(1/gamma). Water-filling reallocates unused
capacity so no cell gets more than it has:
gamma = 1is a proportional cut: every neighborhood keeps the same fraction.gamma > 1is sublinear: dense neighborhoods keep proportionally fewer features, and sparse ones proportionally more.
This is tippecanoe's -g gamma dot-dropping ("reduce dots to the 1/gamma
power in dense areas"), applied per super-cell. --drop-gamma does not
change per-level totals. It only redistributes which features survive
spatially, so it is independent of --drop-rate.
Points often stay below the budget because --point-thinning (default 4)
already removes many, as with New York City points of interest. Changing
--drop-rate then has little effect; use
clustering. Lines and polygons
use thinning factor 1, so the budget usually affects them more.
--no-density-drop turns the budget off, leaving pure cell-winner
thinning and a footer without the budget block. It affects only the mid-zoom
plateau. The geo:overviews → generalization.density_drop provenance in
the footer records the mechanism and its parameters (drop_rate, gamma,
supercell_gsd_factor).
Clustering: --cluster, --accumulate-attribute¶
--cluster (opt-in, duplicating mode only) counts points that lose their
thinning cell under the winner:
- Every output row gains
point_count(INT64 NOT NULL), the number of source features it represents, following tippecanoe and supercluster. Canonical rows, lines, and polygons always carry 1. - Winners keep their own geometry and attributes. Unlike supercluster's centroids, these deterministic anchors remain at real features.
- Clusters are computed per level. A point absorbed at z4 may win at z6
with its own smaller cluster. At each level,
sum(point_count) == total source point count: the clusters partition the dataset at every level's grid.
For dense points such as places or addresses, scale symbols by
point_count to show how many features each represents.
Clustering changes the --point-thinning default from 4.0 to 16.0.
Counts retain the discarded points' contribution, allowing a sparser grid.
16 × GSD gives about one dot per 16 display pixels; supercluster's default
radius is about 40 px. In the New York City sweep over factors 4, 16, and
48, each 4× step shifted the density ladder by two zooms. An explicit
--point-thinning overrides either default.
--accumulate-attribute COL:OP (repeatable, requires --cluster) replaces
the winner's numeric value with the cluster's sum, max, min, or mean:
tylertoo overview places.parquet places_overview.parquet \
--min-zoom 0 --max-zoom 14 \
--cluster \
--accumulate-attribute population:sum \
--accumulate-attribute confidence:mean
Aggregation and counts¶
- tylertoo computes aggregates per level from source values, never from
coarser aggregates, so
meanis exact at every level. - Nulls do not contribute. A cluster whose members are all null keeps the winner's null. Non-accumulated columns keep the winner's own values.
- NaNs do not contribute or count toward the mean.
- Infinities count.
±infis an ordinary summand, sovalue:maxover{1.0, +inf}is+infat every level. That agrees with the canonical level, which carries the source value verbatim. - Aggregation runs in
f64and writes back in the column's original type. Ameanover an integer column rounds to the nearest integer, so prefer float columns formean. - The column must exist and be numeric, or the conversion fails early.
- The density budget can defer a cell winner and leave its cell without a representative. Those features attach to the nearest surviving point at that level, so the counts still sum correctly.
- Partitioning mode is rejected: a row serves several zooms through prefix reads, but one stored count cannot describe every zoom's grid. Finer rows would also double-count points already absorbed by coarse winners.
The geo:overviews → generalization.clustering provenance in the footer
records clustering (enabled, point_count_column, accumulated: [{column, op}]).
tylertoo validate checks that the column exists as INT64 NOT NULL and that
canonical-level values are all 1.
Line coalescing: --no-coalesce-lines, --coalesce-junction-angle, --coalesce-snap, --coalesce-max-level-rows¶
Short road or river segments can disappear below the visibility gate
(--line-visibility × gsd), leaving disconnected fragments after thinning.
Line coalescing, on by default, joins touching compatible segments into
stroke LineStrings before gates and thinning at each non-canonical
duplicating level. Pass --no-coalesce-lines to disable it.
- The gate tests a chain's extent, allowing short segments to survive together as one visible line.
- Chains never merge across class values when a class ranking is active,
whether an explicit
--class-rankor an automatically detected Overtureclassorroad_class. With no class ranking, all lines are compatible. - Junctions, where three or more compatible endpoints meet, end chains by
default and preserve network topology.
--coalesce-junction-anglecan continue the most closely aligned pair through them. - The merged feature keeps the attributes of its highest-priority member,
in the same class-rank, size, and hash order as the cell-winner stage. The
output gains a
coalesced_countINT32 NOT NULLcolumn: the number of source segments merged into the row. It is 1 for unmerged rows and for every row at the canonical level, which never coalesces. Tiles omit the column when it is 1 everywhere, since it then says nothing about the data. The overview file keeps it. - Points and polygons stay untouched. MultiLineString rows pass through unmerged.
# Coalescing is on by default (auto class ranking groups by road class):
tylertoo overview roads.parquet roads_overview.parquet \
--min-zoom 0 --max-zoom 14
# Opt out (no coalesced_count column):
tylertoo overview roads.parquet roads_overview.parquet \
--min-zoom 0 --max-zoom 14 --no-coalesce-lines
--coalesce-junction-angle (default 0 = off, degrees)¶
Chains stop at junctions by default, which rendered better in the Portland sweep. An angle allows the straightest pair to continue if its deviation is within the limit, then repeats for the next pair. Both through-streets can continue across a four-way crossing. Larger angles give fewer, longer strokes but can merge real turns and smear attributes across crossings. At 30°, Portland's z0–z1 gains large arterial strokes.
--coalesce-snap (default 1.0, GSD multiples)¶
Exact endpoint matches always chain, including shared Overture and
OpenStreetMap nodes. Snapping also joins chain ends within factor × gsd.
Raise the factor to bridge larger gaps, at the risk of joining nearby
parallel lines. 0 leaves exact matches only.
--coalesce-max-level-rows (default 2,000,000): memory guard¶
Chaining holds all candidate line geometries in memory, including fragments
otherwise dropped. Above either limit below, tylertoo skips coalescing and
warns which limit was exceeded. The overview keeps coalesced_count (all 1)
and provenance; tiles omit the all-1 column. Large levels tend to be near
canonical, where individual segments are visible and coalescing matters
less. This allocation is O(lines) even in streaming mode.
The guard checks both rows and modeled bytes. For 2M lines, the model estimates 168 MiB for two-point roads but about 15 GiB for 500-vertex contours:
| Limit | Default | What it counts |
|---|---|---|
| candidate lines | --coalesce-max-level-rows (2,000,000) |
line features in the input |
| retained geometry | --coalesce-max-level-rows × 512 B (1.024 GB, about 977 MiB) |
a fixed model: a 56 B slot plus 16 B per vertex, per line |
The byte model depends only on vertex counts, independent of the machine,
allocator, or geo version. Allocator headers, Vec slack, and side vectors
add about 100–120 B per line: the default 1 GB modeled limit means about
1.2 GiB resident. Short lines have more overhead relative to geometry;
2M two-point segments measured about 400 MiB resident against 168 MiB modeled.
The byte limit binds first when lines average more than about 28 vertices
(512 = 56 + 16 × 28.5). Depending on line count, about 57–64M vertices
cross it: 2M lines at 29 vertices, 1M at 61, or 250k at 253.
- Not affected: road networks split at intersections. Overture transportation segments average about 8 vertices (about 190 B modeled), so the row limb binds for them.
- Affected: unsplit line data, once there are enough lines to cross about 1 GB modeled. Examples include OpenStreetMap ways kept whole, rivers, streams, administrative boundaries, coastlines, contour lines, and GPS tracks.
Raise --coalesce-max-level-rows to scale both limits: 4,000,000 permits
4M lines or 2.048 GB modeled. Allow about 1.2× modeled bytes for resident
geometry, plus the larger chain-stage peak below, or disable coalescing.
Both limbs are pure functions of the input, so the verdict and the output are
identical across machines, engines (--no-streaming or not), and
--read-batch-size values. Pass 1 enforces the limbs while it collects:
the moment the running totals cross, it frees what it has buffered and falls
back to counting. On a 6M-line synthetic input, that cut the macOS peak
memory footprint from 4890 to 1157 MiB with byte-identical output.
Chain-stage memory¶
The guard does not bound chaining itself. Pass 1 chains the whole line scratch for every planned level to obtain the row counts needed by the plan. Pass 2 reuses those tables.
The levels run in parallel, in waves sized against the --profile memory
budget: 60% of available RAM under auto and bounded, unbounded under
speed. At most budget / (2 × modeled line bytes) levels run at once, and
never fewer than one. A tighter budget costs wall time, never output. Peak RSS
on Linux (/usr/bin/time -v) for 1.5M 7-vertex lines in chains of ten
(240 MiB modeled):
| run | unbounded budget | capped budget |
|---|---|---|
tiles, z0–z14 |
4667 MiB, 19 s | 3525 MiB, 19 s (8 GB RAM, 9 levels per wave) |
overview defaults (6 of 7 levels empty) |
2386 MiB, 2.4 s | 1373 MiB, 5.6 s (2.5 GB RAM, 2 levels per wave) |
tiles --plan-only |
4973 MiB, 4.6 s | 1845 MiB, 9.6 s (2.5 GB RAM, 2 levels per wave) |
With --no-coalesce-lines, the same tiles z0–z14 run peaks at 935 MiB.
Pass 1 must chain levels before it can know they are empty. For the lowest
peak, set TYLERTOO_AUTO_MEM_LIMIT_BYTES low enough for one level per wave,
or disable coalescing.
Interactions¶
--line-visibilityand--line-thinningact on chains. The gate tests the merged extent, and one chain, not one segment, survives per thinning cell. Expect coarse levels to show fewer rows but much more line length than a run without coalescing.- Class ranking does double duty: it picks which chain wins a cell and
defines the compatibility groups.
--no-auto-rank, or a numeric--sort-key, makes all lines compatible. That suits single-class datasets such as rivers and is usually wrong for mixed road networks. - The density budget applies to chains. After the gate and thinning,
each level keeps at most
num_lines / drop_rate^(finest − level)chains, with the same ladder, floor, and spatial-fairness gamma as the point and polygon budget, cutting the lowest-priority chains first. Without it, the reclaimed fragments would re-inflate the mid-zoom counts the budget caps.--no-density-dropdisables the chain budget too. Points and polygons keep the row-level budget. - Partitioning mode: inert. Partitioning places each feature exactly once
with verbatim geometry. A merged chain is a new geometry replacing several
source rows, which that contract cannot represent. Partitioning conversions
run without coalescing (no
coalesced_countcolumn, no provenance, aninfolog line). An explicit--coalesce-lineswith--mode partitioningis an error.
The geo:overviews → generalization.coalescing provenance in the footer
records the complete knob set (enabled, snap_tolerance_gsd_factor,
junction_angle, max_level_rows, and coalesced_count_column, per spec §13.4).
tylertoo validate checks that the column exists as INT32 NOT NULL, that
every value is at least 1, and that canonical-level values are all 1.
Reserved column names¶
tylertoo reserves names for its appended overview columns, ignoring case.
A colliding source name gains _ suffixes until free:
level becomes level_, LEVEL becomes LEVEL_, and level_ becomes
level__. The reserved column is authoritative.
| Reserved name | Reserved when | Reaches MVT? |
|---|---|---|
level |
always | no: dropped from tile properties |
point_count |
--cluster |
yes |
coalesced_count |
line coalescing (on by default) | yes, unless 1 on every row (then omitted from tiles, but kept in the overview file) |
tylertoo logs a rename at warn:
input column "level" collides with the reserved overview column "level";
renaming the input column to "level_" in the output
Export restores source names when free. tylertoo's level never reaches
vector tile (MVT) properties, so a source level is restored in tiles:
overview.parquet: level_ (your data) + level (tylertoo's)
out.pmtiles: level (your data)
The same holds for a standalone export-pmtiles run on an earlier overview
file. The footer records the rename (geo:overviews.generalization.renamed_columns,
output name → source name), so the export does not need the converting run's
state.
Source point_count and coalesced_count columns keep their renamed names
when the reserved columns reach MVT. If tylertoo omits an all-1
coalesced_count, the source column regains its original name.
By-name options follow the rename automatically (--sort-key level,
class_ranking.column, --accumulate-attribute, --filter), so you never
spell the renamed name yourself.
Output feature order: --feature-order¶
MVT does not define draw order, but renderers paint features in tile order unless a style overrides it. Changing that order can change the map.
The default is input row order: source order restricted to the rows each overview level kept. A nested-polygon fixture at z12–z14 has zero within-tile inversions against source order.
Tippecanoe's order is unspecified and varies by tile and zoom, sometimes
reversing within one input. Set --feature-order when paint order matters:
# Source row order (the default)
tylertoo tiles in.parquet out.pmtiles --feature-order input
# Sort within each tile by a property: high `level` painted last (on top)
tylertoo tiles in.parquet out.pmtiles --feature-order level -f
# ...or first (underneath)
tylertoo tiles in.parquet out.pmtiles --feature-order level:desc -f
Both tiles and export-pmtiles accept it.
For a nested-polygon choropleth, sort small high-value shapes above larger
low-value shapes. --feature-order level sets the archive order equivalent
to a style's "fill-sort-key": ["get", "level"].
Sorting rules¶
- Ties keep input order. The within-tile sort is stable over the
(tile, row)order, so two runs of the same build produce byte-identical tiles. - Ordering is per tile. Nothing moves across tile boundaries. The option changes the sequence within a tile and nothing else.
- Numeric types compare as numbers. A column read as an integer in one row group and a double in another is one ordering class, not two.
- A feature missing the property sorts first, painted underneath, rather
than dropping. A
NaNsorts the same way: no style can rank it, so it joins the unrankable features underneath instead of taking an arbitrary place among the numbers. - Sorting is independent of the oversized-tile valve. With
--max-tile-sizein force,select_kept_membersdecides which features survive: largest first, or a uniform spatial stride on point-dominated tiles.--feature-orderthen orders the survivors. - The name is the key the tile advertises, not the schema column name.
Export restores a source column renamed to clear a reserved name (see
Reserved column names), so sort by the source
name.
--feature-order levelis right for an input with its ownlevelcolumn, even though the overview file stores it aslevel_. A column stays renamed when tiles publish the reserved name, as they publishpoint_countunder--cluster. Name such a column as it appears in the tile. - An unpublished column produces a warning listing available property names. All features remain unranked and keep input order.
--feature-order input is the default and costs nothing. Naming a column adds
one stable sort per tile over features already in memory.
Stable feature ids: --feature-id¶
Every MVT feature carries an id, defaulting to its position in one tile
at one zoom. This tile-local id can change across tiles and zooms.
--feature-id COLUMN writes the column's value as the feature id instead, on
every tile and zoom the feature appears in. It matches tippecanoe's
--use-attribute-for-id:
tylertoo tiles buildings.parquet buildings.pmtiles --feature-id building_id
tylertoo export-pmtiles overview.parquet out.pmtiles --feature-id building_id
Stable ids let MapLibre feature state and external joins follow a feature
across tiles and zooms without promoteId. For example:
map.setFeatureState({ source, sourceLayer, id }, { hover: true });
promoteId still works on any property, with or without --feature-id.
Stable id requirements¶
- Integer columns only:
Int8toInt64,UInt8toUInt64, or an unscaledDECIMAL(p,0). MVT ids are unsigned 64-bit integers. Tippecanoe also parses numeric strings and integral floats, and tylertoo rejects them. A string id, such as an Overture GERS id, has no lossless integer form. Cast or hash it to an integer column before tiling, withgpioor DuckDB (for examplehash(id)::UBIGINT), and keep the original string as a property if clients need it. - Every row must hold a value in
0..=2^64-1. Nulls, negatives, and out-of-range decimals fail export. Unlike tippecanoe, tylertoo does not wrap-5to18446744073709551611. It checks every overview level before writing tiles. Errors name the overview row and level, since conversion reorders source rows. - The column moves rather than copies. It becomes the id and leaves the
tile properties and
vector_layers, whatever--include-propertyand--exclude-propertysay at export. To keep it as a property too, carry it under a second name in the source. Ontiles, the convert step must keep the column, so an--exclude-propertynaming it is an error there. - It cannot be the
--feature-ordercolumn, because that sort reads the tile properties the id left. Nor can it be an--accumulate-attributecolumn, whose clustered value is a sum or mean of several ids. - Aggregated features keep one member's id. A cluster (
--cluster) carries its representative point's id. A coalesced line chain carries its highest-priority member's id. A tiny-polygon placeholder carries its own polygon's id. tylertoo does not check ids for uniqueness. Duplicate values give features that share an id, and feature state then applies to all of them at once.
pyramid does not take --feature-id, and neither do the Python
pyramid() and convert() one-shot. Use overview() and then
export_pmtiles(..., feature_id=...) instead.
Tile geometry knobs: --tile-buffer, extent¶
Both settings apply at export.
Tile buffer¶
--tile-buffer (default 8, maximum 256) extends geometry past tile edges
to render seams continuously. Units are pixels of a nominal 256-pixel tile,
matching tippecanoe's --buffer (default 5).
At 256, a tile already includes features from all eight neighbors. Larger buffers would reach nonadjacent tiles and risk O(features × tiles) export, so tylertoo rejects them before conversion or export. Sharded builds read two pivot tiles (512 px) beyond their range, covering this margin. See Scaling.
Tile extent¶
extent (default 4096) sets tile-local coordinate resolution through
Python's export_pmtiles(extent=...); there is no CLI flag. It must be
positive. At 0, geometry quantizes to the origin, lines and polygons
degenerate, and consumers divide by zero. Both export and tylertoo decode
reject zero extents.
The MVT spec recommends a power of two, which decoders assume for coordinate precision. Other positive values are accepted with a warning.
Export and archive commands: export-pmtiles, merge, stats¶
Tile size cap¶
--max-compressed-tile-size caps each tile's gzip-compressed size, like
tippecanoe's -M, at 500K by default. export-pmtiles --tile-size-limit,
also spelled --max-tile-size as on tiles, caps the raw encoded MVT at
2M by default: a backstop, since clients decode the raw tile and
full-resolution polygons can compress 4:1. A tile must fit both caps, and
0 disables either. Until #761 the only default was a 500K raw cap, about
twice as strict as tippecanoe's: on dense fields it merged nearly twice as
many features (FTW Moldova: 901k against 490k) for no coverage gain.
A tile over a cap is reduced for that tile only, and measured again until it
fits. --size-valve chooses how:
merge(the default) merges the densest lines and polygons into the feature kept just before them, raising the merge threshold until the tile fits. See what merging does to attributes. A tile that twice in a row shrinks too slowly to fit within a few more passes, which happens when the merged features do not touch, merges everything it can in one step instead. If the tile still exceeds the cap after all possible merges, unmerged features are dropped, keeping the largest. Merged features are retained. A tile that is still over after that is written as it is, with a warning.dropdrops features, keeping the largest for polygons and lines.
For point tiles, the size valve retains points at uniform spatial intervals.
Polygon clipping¶
By default, a polygon whose rings are already
simple skips the i_overlay boundary-bridge fallback when tylertoo clips it
to a tile. Fine-zoom polygon export runs faster and renders the same, but a
simple ring comes out rotated to a different start vertex. Pass
--no-simple-clip-fastpath when you need byte-stable tile output.
Declared minimum zoom¶
export-pmtiles --min-zoom sets the minimum zoom
the archive declares in its metadata (vector_layers[].minzoom), even when
the overview file lacks its coarsest levels. overview omits a level that
generalizes to nothing, so a file built for z0–13 can start at z2. This flag
records the requested z0 anyway. The PMTiles header's minimum zoom does not
widen: it always reports the shallowest zoom that holds a tile, as
go-pmtiles verify requires. A renderer that reads the header gets z2, and
the empty zooms would render nothing either way. The value must not be finer
than the coarsest level present. Unset, it is the coarsest level's zoom.
tiles and Python's convert() pass their own --min-zoom here.
Property selection at export¶
export-pmtiles --include-property,
--exclude-property, and --exclude-all-properties act on the tiles only.
See Property selection.
Encode tallies¶
--report, and the return value of
Python's export_pmtiles(), carry per-zoom tile and feature counts and the
oversized-tile tally. They also carry two encode tallies, in total and per
zoom:
encode_dropped_featurescounts tile members with nothing to encode: empty geometries or empty GeometryCollections. A non-zero value means clipping lost content. A warning names the total, and the summary line repeats it.encode_quantized_featurescounts tile members whose geometry collapsed at the tile extent: zero-area polygon rings or lines of fewer than two points, typically clip slivers at a buffered tile edge. Ordinary data produces these, and they never warn.
Sharded export¶
--tile-range LO..HI takes two tile ids at the same
pivot zoom and emits every descendant of those tiles at every deeper zoom. The
ids under one tile are contiguous on the Hilbert curve, so the restriction is
an exact interval test at each zoom. Tiles coarser than the pivot are not
emitted. --zoom-ceiling Z emits only the zooms at or below Z, the coarse
half. It is a ceiling, not a --max-zoom, because it chooses which zooms to
emit, while --min-zoom only widens what the metadata declares. The overview
file still holds every level. Scaling
covers both in context.
tylertoo merge¶
merge takes two or more inputs that hold disjoint tile ids
and agree on tile type and tile compression. The merged archive's bounds,
zoom range, and vector_layers are the unions of the inputs'. Layers sharing
an id collapse into one entry spanning their combined zooms, with the union
of their fields. A shard's ids need not form a contiguous slice of the id
space, because subtrees at different depths interleave on the Hilbert curve.
tylertoo therefore checks disjointness per tile id, not per range.
--work-dir holds the spool file of merged tile data until merge assembles
the archive. Set it when the output is large and /tmp is a small tmpfs.
--report writes per-zoom tile counts for checking a sharded build. For
overlapping archives, such as different data at different zooms or several
layers over the same zooms, use tylertoo pyramid.
merge and pyramid also take --name, --description, and --attribution;
an unset field is inherited from the inputs. The
attribution guide has the
rules.
tylertoo stats¶
For each zoom, stats prints the tile count and the total,
mean, p50, p99, and maximum tile size. --largest N adds the largest tiles by
z/x/y, and --json prints the same numbers machine-readably:
tylertoo stats buildings.pmtiles --largest 5
tylertoo stats buildings.pmtiles --json
Sizes are stored (compressed) bytes per addressed tile, read from directory
entries without reading or decompressing tiles. Run-length
members and deduplicated tiles each count at the full length of their shared
body. The total column is therefore the bytes a client would fetch, and it
can exceed the archive's size on disk. Percentiles are nearest-rank: pN is
the smallest size such that at least ⌈N/100 × tiles⌉ of the zoom's tiles are
no larger. The largest tiles sort by size, descending, with ties broken by
ascending PMTiles tile id. Memory and time scale with the archive's directory
entries, not its tile count, because runs aggregate with their multiplicity
rather than expanding.
File layout knobs: --row-group-size, --full-column-stats¶
These settings change Parquet layout, footer size, and bbox pruning. Geometry and attributes remain byte-identical.
--row-group-size (default 10000): per-level row-group sizing¶
--row-group-size caps rows per group separately for each level:
- A level at or below the cap uses one group, fetched whole for a coarse view.
- Larger levels use
ceil(features / row-group-size)roughly equal groups, allowing bbox statistics to prune fine-level reads to a viewport.
Each level always ends on a row-group boundary, and no row group mixes two levels (spec §4.2), whatever the knob says.
Smaller groups tighten bbox pruning but enlarge the footer; larger groups do the reverse. The default 10000 balances both. String and geometry statistics are suppressed by default, keeping footers small even with hundreds of groups. Lower the cap for tiny viewports over high-latency storage.
Parquet's i16 group ordinal limits files to 32,768 groups. Before pass 2
opens the output, tylertoo projects group counts from pass 1's winners and
checks a 32,000-group ceiling, leaving headroom for the estimate taken before
simplification. Above it, the cap rises to the smallest clean value that
fits. A warning logs both caps; the summary and --report field
effective_max_row_group_size record the applied value.
A raised cap needs more memory because the writer buffers a whole group.
To reduce group counts, plan fewer levels with --min-zoom and
--max-zoom, or use --row-group-size-policy zoom-scaled for larger coarse
groups.
--row-group-size-policy (default constant): per-level cap scaling¶
This sets how the --row-group-size cap applies across levels:
constant(default): every level uses the same cap.zoom-scaled: the cap doubles for each zoom step below the finest level,cap = row-group-size << (max_zoom − level_zoom). Coarse bands, which a wide viewport reads mostly whole anyway, collapse into fewer and larger row groups and fewer remote requests. The finest level keeps tight per-row-group bbox pruning.
Use zoom-scaled when a deep pyramid's coarse levels fragment into many tiny
row groups and bloat the footer. Otherwise leave it constant. Decision 5 in
corpus/SWEEPS.md
gives the evidence.
--full-column-stats (default off): statistics suppression¶
The writer suppresses per-group min/max statistics on WKB geometry and
string or binary properties, such as Overture's 26-character ULID id.
The overview read protocol uses bbox covering and level statistics,
which always remain enabled (spec §4.4).
Suppressed statistics can dominate the Thrift footer, which every remote query reads in full. On Moldova polygons (631k features, ULID ids), full statistics make an 8.84 MB footer, larger than most viewport reads. Suppression reduces it to under 1 MB.
Pass --full-column-stats to keep statistics on all columns. Do this only if
remote clients push predicates on property columns, such as WHERE id = …
or WHERE class = 'motorway', and want row-group skipping on them. You trade
a bigger footer for that pushdown.
Memory / streaming knobs: --no-streaming, --read-batch-size¶
These settings control conversion memory without changing output content. How streaming bounds memory explains the two-pass pipeline and its pass-1 floor of up to 42 bytes per row. See also the file layout knobs.
| Knob | Default | Units | Direction |
|---|---|---|---|
--read-batch-size N |
8192 |
rows per read batch (max 1048576) | bigger = slightly faster, more memory |
--no-streaming |
off | flag | use the one-pass in-memory pipeline |
Read batch size¶
--read-batch-size bounds transient memory in both passes. The default
keeps each batch's working set in the tens of MB even for vertex-heavy
polygons. Try 1024 for limited RAM or huge multipolygons; try 65536 only
when profiling shows batch overhead and RAM is available.
Pass 1 divides each batch across rayon threads in chunks of
read_batch_size / threads, clamped to 256–1024 rows. Smaller batches
reduce parallelism but never disable it.
In-memory conversion¶
--no-streaming holds the whole table and all decoded geometry,
using O(dataset) memory. It decodes geometry once rather than re-decoding
winners in pass 2, so small inputs may run slightly faster. Large inputs
run slower and need far more RAM. This is the equivalence-tested reference
implementation and a fallback, with no output-quality advantage.
The whole table lives in one Arrow batch, and Arrow's i32 offsets cap one
byte-array column at 2 GiB per batch. When a byte-array column (WKB geometry
or a string) sums past that over the row groups to read, --no-streaming
fails before reading any data and names the column. The figure comes from
footer statistics. A --bbox or --filter that prunes row groups lowers it.
The streaming pipeline has no such limit.
During pass 2, the winner table holds 1 byte per feature, about 0.6 MB for a 632k-feature file.
The pass-1 feature table is held from pass 1's scan through level
assignment. It costs 33 bytes per input row, 41 with a sort key (explicit or
detected from the schema), and 42 with a sort key and an entry-zoom
ladder. The transient scan vectors raise the scan-time peak to about 42–80
bytes per row. The table is the coarse job's memory floor on a
sharded build and reaches
tens of GiB at a billion rows. Before pass 1, tylertoo checks it against the
footer row counts (#543). It warns when rows × 42 B × 3.3 exceeds the memory
figure. It fails only when the floor alone exceeds a hard cgroup limit and no
--bbox or --filter is active. TYLERTOO_SKIP_MEMORY_PREFLIGHT=1 turns the
error into a warning.
Regional extract: --bbox¶
--bbox xmin,ymin,xmax,ymax converts only features whose bounding box
intersects the region, in longitude and latitude degrees (EPSG:4326).
Remote reads explains how covering
statistics let it skip row groups, and what makes a source prunable.
# Tile Antananarivo from a Madagascar-wide file
tylertoo overview madagascar.parquet antananarivo.parquet \
--bbox 47.4,-19.0,47.6,-18.8 \
--min-zoom 0 --max-zoom 14
Use degrees even for EPSG:3857 input; the converter reprojects the box.
A feature also matches when its copy one world width away (±360°)
intersects the box, because export draws it there. So
--bbox -170,0,-150,20 selects a 0..360 feature stored at longitude 200,
and a box around longitude 179.5° W selects a cell stored at
x: 178 … 181.
Without covering statistics, every row group is read before the same
per-feature test. Compare ConvertReport.row_groups_read with
row_groups_total to check pruning.
Attribute filter: --filter / --where¶
--filter <EXPR> (alias --where) selects features using a SQL
WHERE-style predicate on input properties. It combines with --bbox on
both overview and tiles.
# Only high-confidence field boundaries, straight from the source file
tylertoo tiles fields.parquet fields.pmtiles \
--filter "confidence > 0.8" --max-zoom 14
# Composes with --bbox and richer predicates
tylertoo overview brazil.parquet subset.parquet \
--bbox=-48.0,-16.0,-47.0,-15.0 \
--where "crop_type IN ('soy', 'corn') AND confidence >= 0.5"
Expression syntax¶
A built-in recursive-descent parser supports:
- Comparisons:
=(or==),!=(or<>),<,<=,>,>=, with the column on the left:confidence > 0.8,country = 'BRA',active = true. - Membership:
col IN (v1, v2, ...)andcol NOT IN (...). - Null tests:
col IS NULLandcol IS NOT NULL. - Boolean composition:
AND,OR,NOT, and parentheses.ORbinds loosest, thenAND, thenNOT. - Literals: numbers (
0.8,-2,1e6), single-quoted strings ('it''s'escapes a quote), andTRUE/FALSE. Keywords are case-insensitive. - Columns: bare identifiers, or
"double quoted"for names with spaces. Supported column types are numeric (signed or unsigned integer, float), string, boolean, and timestamp. - Timestamp columns compare against single-quoted datetime strings:
time >= '2025-01-01',time < '2025-06-01 12:30:00', or full RFC 3339 with an offset. A literal without a timezone reads as UTC, because Arrow timestamps store a UTC instant whatever the column's display timezone. A malformed datetime fails at startup, not mid-run. Statistics pushdown works when the file stores timestamps as INT64, as modern writers do. Legacy INT96 timestamps, from older Spark, expose no statistics, so those predicates filter exactly but prune nothing.
Nulls and non-finite values¶
Filters use SQL three-valued logic:
- A comparison or
INover aNULLisUNKNOWN. AND,OR, andNOTcombine with Kleene logic.- A row stays only when the whole predicate is
TRUE.
So confidence > 0.8 drops null-confidence rows, and so does
NOT (confidence > 0.8). Use IS NULL and IS NOT NULL to test nulls
explicitly.
NaN comparisons are UNKNOWN, including != and NOT IN; infinities
compare normally. For example, NaN != 5 is TRUE in IEEE-754 but
UNKNOWN here. NaN is present rather than null: IS NULL does not match
it, and IS NOT NULL does. To select these rows by value, clean the column
upstream, for example with gpio, or filter on another column.
Pruning and pipeline order¶
Like --bbox, the filter checks row-group statistics before it reads data.
It then evaluates each row exactly in pass 1, so the output is identical
whether pruning fired or not. See
Remote reads. AND intersects the prunable
sets and OR unions them. NOT (...) subtrees and columns without usable
statistics keep the row group.
The filter runs before level assignment, ranking, density budgets,
clustering, and coalescing. Rejected features never enter the pipeline.
ConvertReport.input_features counts only survivors. Compare
row_groups_read with row_groups_total to see whether pruning fired.
Sorting the input by a filtered column, or lowering --row-group-size,
tightens per-row-group statistics and prunes more.
Property selection: --include-property / --exclude-property / --exclude-all-properties¶
These flags select output properties, like tippecanoe's -y, -x, and
-X. On overview and tiles, excluded columns are never decoded or
written to the overview. Smaller tile properties leave room for more
features under --max-tile-size. Geometry always stays.
The GeoParquet bbox covering struct is not a property either. The writer
builds a fresh covering from the output geometry, so a selection never reads
the input's covering. --include-property bbox is an error on every
command, and --exclude-property bbox only warns. A covering declared in the
file's geo metadata counts under any name.
# Tiles carry only confidence and metrics:area
tylertoo tiles fields.parquet fields.pmtiles --max-zoom 13 -f \
--include-property confidence --include-property metrics:area
# Everything except the id and the timestamp
tylertoo overview fields.parquet ov.parquet --exclude-property id \
--exclude-property "determination:datetime"
# Geometry only
tylertoo tiles fields.parquet outlines.pmtiles --exclude-all-properties
| Knob | Default | Semantics |
|---|---|---|
--include-property COL (repeatable) |
all | keep only these. Naming a column the input lacks is an error |
--exclude-property COL (repeatable) |
none | drop these. An unknown name only warns. An include list overrides it |
--exclude-all-properties |
off | geometry-only output. An include list overrides it |
An include list overrides all exclusions, matching tippecanoe, where -y
implies -X. For example,
--exclude-all-properties --include-property foo keeps foo, and
--include-property a --include-property b --exclude-property b keeps both.
Without an include list, --exclude-all-properties keeps nothing and
--exclude-property drops what it names.
Columns required by other options¶
Columns used by these options must stay included:
--sort-key, --class-rank, --magnitude-ladder, --entry-zoom,
--accumulate-attribute, and --filter. Excluding one is an error naming
the option that needs it. The check matches --filter columns the way the
filter does, so --filter "NAME > 0" --exclude-property name fails with the
same error.
Automatic ranking considers class, road_class, and confidence when
present, and checks each column's values before it ranks on one. Excluding a
candidate logs one line naming it and the candidates still in play. The
excluded column is never read, so that line cannot say whether it would
have ranked. The overview ranking: line reports the column that does. With
no candidate left, ranking falls back to feature size.
export-pmtiles applies the same flags to published tile property names,
leaving the overview untouched. The --feature-order column must remain;
tiles checks this before conversion too. At export, an explicit
--include-property coalesced_count retains even an all-1 column. Include
lists still override exclusions; an included property the file cannot
export is an error.
Column types¶
tylertoo exports struct, list, and map columns as JSON
strings, tippecanoe's convention for nested attributes. So
--include-property names on an Overture file keeps the whole names struct
as one string property. A column with no MVT encoding, such as a binary
column, drops with one warning per column and appears in the export report's
skipped_property_columns. tylertoo rejects it by name if --include-property
asks for it, and an --exclude-property naming it silences the warning. The
full type table is in preparing input.
Performance profiles: --profile, --in-flight-batches, --read-workers¶
These settings control speed and memory. Output is byte-identical across profiles, batch concurrency, read-worker counts, and thread counts, as with the memory and streaming knobs.
The Scaling guide covers memory profiles,
read concurrency, and
spill files in depth, including container
memory limits and --spill-dir.
Pass 2 reads Parquet once and pipelines reads, decoding, and simplification across all cores. The profile decides how much memory every phase may hold: where output rows wait before writing, how many batches are read ahead and processed at once, and how many row groups the writer encodes at once.
| Option | Default | Effect |
|---|---|---|
--profile speed\|bounded\|auto |
auto |
How much memory the run may hold |
export-pmtiles --profile speed\|bounded\|auto |
auto |
The same choice for a two-step export; tiles passes its --profile to the export. Export's member store, level stores and wave width follow it as below |
--in-flight-batches N\|auto |
cores, clamped to 4–16 | More batches = more overlap and memory |
--read-workers N\|auto |
pass 1 cores/2, pass 2 cores/4, at most 4 | More readers in each pass = more throughput and resident batches |
speedsets no limit. Each level's rows stay in RAM, and every knob runs at its maximum: every requested reader at the full read-ahead depth, the whole compute window, and one row-group encode per thread. It runs in the least wall time with no temp I/O, but peak RAM grows with total output size.boundedholds the least. Each level's rows spill to temporary Arrow IPC files and stream back at write time. Every other phase keeps the capsboundedalways had, and the allowance can only shrink them: at most 4 readers, each at most 16 batches deep, and at most 4 concurrent row-group encodes. Remote staging holds at most 256 MiB of requests in flight, orTYLERTOO_STAGING_MEM_BUDGET_BYTESwhen set. The compute window takes what--in-flight-batchesasks for, within the allowance. Its peak never exceeds whatboundedused before the run budget existed (#788), whatever the machine.auto, the default, sizes the run to the machine. Every phase fits inside one allowance: 60% of available memory, or ofTYLERTOO_AUTO_MEM_LIMIT_BYTESor the cgroup limit when set. The read-ahead, the compute window and the writer's encodes each get a share of it, priced from the Parquet footers and charged in turn, so each phase sees only what the earlier ones left (#716, #545). Output rows stay in RAM when their estimate fits and spill when it does not, and a run that starts in RAM keeps measuring and switches to spilling mid-pass if it outgrows the allowance. Both checks use what the earlier grants left. When those grants take the whole allowance, as the writer's encodes can for wide rows under a small limit, output rows spill from the start.
Export applies the same rule. speed keeps every level's tile members in
RAM and sizes the partition wave from the cores alone; bounded spills them
and keeps the wave caps export always had; auto keeps them in RAM when they
fit the allowance and narrows the wave to fit it. Under every profile, export
still refuses a tile that alone needs more than the machine's memory
(Export waves).
auto is a third shape, not a switch between the other two: on a large
machine it holds more than bounded and less than speed. On a machine
with about 90 GB available, Moldova (632k polygons, z0-14) under auto
encodes 64 row groups at once where bounded encodes 4. Under a 2 GiB limit
the same grants shrink: NZ parcels (4.3M polygons) gets 5 encodes and a
window of 1 batch. To hold auto down, set a smaller limit with
TYLERTOO_AUTO_MEM_LIMIT_BYTES or a cgroup memory limit, or use bounded.
Under auto and bounded, each read worker is also charged for the row
group its reader decodes (the largest selected row group at the footers' decoded row size),
so inputs with large row groups get fewer readers under a tight limit. Under
a cgroup limit the kernel charges the spill's page cache to the same limit.
When the spill's footer-priced size fits the allowance, pass 2 reserves it
before any grant. When it does not fit, nothing is reserved, because the
cache cannot hold it and reserving it would only starve the grants.
TYLERTOO_PROFILE_JSON records the decision under pass2.spill_reservation.
Release binaries use the mimalloc allocator, which is faster than the
system allocator but can keep freed memory mapped, so a run's RSS can sit
above its live heap. That gap is allocator retention, not a leak. --profile bounded
remains the setting for memory-constrained machines.
bounded keeps main's footprint for the same allocator. The release CLI
uses mimalloc by default, which adds a fixed floor of about 600 MiB that
shows on small inputs: under bounded, Madagascar (z0-12) peaks at 1,254 MiB
against 685 MiB with the system allocator, and Moldova at 2,792 against
2,509 MiB, while running faster (Madagascar 1.4 s against 3.0 s). The floor comes from transparent huge pages on
mimalloc's eagerly committed arenas and its 1 s delay before freed pages go
back to the OS. MIMALLOC_ALLOW_THP=0 MIMALLOC_PURGE_DELAY=10 in the
environment removes it (Madagascar 566 MiB, Moldova 1,665 MiB) but gives back
most of the speed (Madagascar 2.0 s, Moldova 12.4 s against 9.3 s). A build
with --no-default-features uses the system allocator.
bounded uses LZ4 framing for its private IPC spill streams; auto and
speed keep raw IPC, including an auto downgrade to disk.
pass2.sink.spill_codec records the selected codec.
Automatic memory estimate¶
auto estimates buffered rows × per-row cost from the average
encoded-geometry size of the rows pass 1 reads, taken from the uncompressed
column sizes in the Parquet footer. Geometry dominates buffered rows and
varies about 400× across datasets. The estimate is conservative, favoring spilling.
If pass 1 scanned nothing, it uses about 8 KiB per duplicating row or
16 KiB per partitioning row. Partitioning always spills above 2M buffered
rows. The log reports the average, estimate, budget, and decision.
TYLERTOO_AUTO_MEM_LIMIT_BYTES overrides the detected available RAM.
That estimate counts geometry only, so a run that starts in RAM keeps
measuring for the whole pass (#626). It sums the Arrow bytes of every
buffered batch, geometry plus every kept property column, with a 256-byte
floor per row. After each input batch, it adds the bytes already buffered to
the rows still to come at the rate so far. If that total passes the budget,
auto moves the buffered rows to spill files once and spills for the rest of
the pass. Row order and output bytes do not change. The switch happens no
later than the moment the buffered bytes reach the budget. The log and
TYLERTOO_PROFILE_JSON (pass2.sink) report the measured bytes per row and
whether it switched. speed and bounded never switch.
Pass-1 winner grids¶
Level assignment builds one winner grid per coarse level. Concurrent grids
can set peak RSS on large inputs with simple geometry: 5.9 GiB on
germany-segments and about 24 GiB at Brazil scale (see [rss] logs).
bounded and auto estimate grid memory against the same budget and split
levels into waves when needed, down to one level per wave. Each level still
uses all cores. Parallel reduction adds transient memory outside the estimate: about
2 MiB per thread across a wave, at least 1 MiB per level, freed after each
reduce. [assign] winner grids … logs any split. With enough RAM, one wave
runs all levels; speed always uses unbounded parallel grids.
Batch concurrency¶
--in-flight-batches sets each pass's channel depth. In pass 2, half of it
is the read-ahead channel and half is a window of batches processed at once.
One batch's expensive geometries or serial decode then don't stall the
batches behind it. Raise it to keep more cores busy.
Each extra batch holds read_batch_size more rows; passes do not overlap.
--read-batch-size sets the rows per batch, and --in-flight-batches sets
how many batches coexist. Each pass's readers add --read-workers × their
queue depth on top. Under bounded, each level's spill writer adds up to three
more batches: two queued and one in the encoder.
Read workers¶
--read-workers decodes disjoint runs of Parquet row groups concurrently
in both passes. An ordered merge restores the single-reader batch sequence,
keeping output byte-identical. Most of a read is page decompression, which
one thread does at under 1 GB/s, so on a machine with cores to spare a
single reader bounds pass 1's wall time (#717).
crates/cli/tests/thread_count_determinism.rs checks 1 against 2 and
4. tylertoo honors an explicit value up to 2× the machine's cores, at
least 4. Above that the CLI rejects it, and the library clamps it with a
warning, because each worker is a thread plus its own read-ahead queue. Each
worker gets about four runs, as far as the row groups allow, so the merge is
not left waiting on one worker's long run. A worker buffers its run ahead of
the merge. A row group larger than its queue
makes the worker wait mid-run, and the reads serialize again. A
[convert] read: debug line reports it. A single-row-group file
always reads sequentially.
For multi-GB partitioning inputs, speed can exhaust RAM: buffered output
may approach input size. auto selects bounded for large partitioning
and over-budget runs. Watch peak RSS if you explicitly choose speed.
Remote staging (env)¶
Pass 0 stages a remote input's selected row groups to the local disk spill
up front, one coalesced range request per row group with several in flight
(#286/#287).
Its heuristics, and the HTTP client under it, accept experimental
environment overrides so the
#290 host sweep
(source.coop vs real S3) can vary them per run without rebuilding. They are
sweep knobs, not stable API: deliberately env-only, with no CLI flags, and
they may change or disappear. They alter request scheduling only, so the
output is identical whatever their values. The sweep that measured them, and
chose to keep the defaults, is in benchmarks/staging/RESULTS.md.
| Variable | Default | Bounds | Effect |
|---|---|---|---|
TYLERTOO_STAGING_CONCURRENCY |
8 | 1–128 | Ceiling on range requests in flight per part |
TYLERTOO_STAGING_MEM_BUDGET_BYTES |
268435456 (256 MiB) | ≥ 1 MiB | When set, caps the requests in flight for every profile. Unset, auto and speed are sized by the run budget, and bounded by the 256 MiB default |
TYLERTOO_STAGING_COALESCE_BYTES |
0 (off) | 0–1 GiB, and ≤ the budget | Merge adjacent selected row groups into one request while the merged span fits the window. A pruned row group always splits a merge, so pruned bytes are never fetched |
TYLERTOO_HTTP_<KEY> |
unset | see below | Sets object_store client option <key> on every remote store |
The window counts toward the memory budget: with
TYLERTOO_STAGING_COALESCE_BYTES=134217728 (128 MiB) and the default
256 MiB budget, at most two merged spans are in flight. A window larger than
the budget warns and is clamped to the budget, since a merged span is held
whole in memory while it downloads.
The window pays off only on a host where time to first byte dominates and
throughput per connection is not capped; on a throttled host it costs
connections and runs slower. Each merged span is one request, and a request
that takes longer than the client timeout (30 s by default) fails. Staging
then gives up on the part and the passes read over the network chunk by
chunk. With a large window on a slow host, raise TYLERTOO_HTTP_TIMEOUT.
TYLERTOO_HTTP_<KEY> forwards any object_store client option, named in
upper or lower case: for example TYLERTOO_HTTP_HTTP2_ONLY=true,
TYLERTOO_HTTP_POOL_MAX_IDLE_PER_HOST=16 or TYLERTOO_HTTP_TIMEOUT=60s. It
applies to https://, s3:// and gs:// inputs alike, after the
scheme's own AWS_* or GOOGLE_* settings, so it wins over them. An unknown
key, or a value the client cannot parse, warns naming the variable and is
ignored. The client defaults to HTTP/1.1, one connection per
request in flight; S3 does not negotiate HTTP/2, so HTTP2_ONLY fails there.
A negative or malformed value warns and keeps the default; a non-negative integer outside the bounds warns and clamps to the bound, including one too large for the underlying type. The values resolve once per process, at the first remote staging, so a long-lived library caller will not see a later change to the environment.
Two lines are logged at staging start, at info when an override is set (so a
sweep run records what it tested) and debug otherwise: the resolved ceiling,
budget and window, and the live concurrency, which is what the run actually
used. Watch the second one — the budget can cap the ceiling, so
TYLERTOO_STAGING_CONCURRENCY=16 and =8 produce identical runs on an input
whose row groups are large enough that budget ÷ largest span is the binding
constraint. A third info line, [remote] client options from
TYLERTOO_HTTP_*, lists the client overrides in force, with a PROXY_URL
value shown as <redacted>.
These warnings and lines go to the Rust log, which the CLI prints at its
default info filter. Python receives them through logging under the
tylertoo_core logger. Python prints only warnings by default, so a bad
value shows up but the info lines do not: call
logging.basicConfig(level=logging.INFO) to see them. Without them, a
misspelled variable name is indistinguishable from the default.
Reusing a plan: --save-plan, --plan¶
Pass 1 scans the input and assigns rows to levels, including density budgets. It often takes most of the conversion time. Save its winner table to repeat builds with different write settings without repeating assignment.
--save-plan PATH saves the result; --plan PATH replays it. Both work on
overview and tiles, require streaming, and are mutually exclusive.
Neither is exposed in Python.
# Once: scan, assign, and keep the plan.
tylertoo overview roads.parquet roads.parquet.overview \
--min-zoom 0 --max-zoom 14 --save-plan roads.plan
# Again, tuning only the write side; pass 1 and the assignment are skipped.
tylertoo overview roads.parquet roads-tuned.overview \
--min-zoom 0 --max-zoom 14 --plan roads.plan \
--profile bounded --row-group-size 50000
The plan contains a header with magic bytes and a checksum, followed by an Arrow IPC file. It stores one winner byte per row plus small per-level tables: a 28 MB, 24k-feature polygon input produces a 49 KB plan. With line coalescing, it also stores collected WKB geometries, increasing its size.
Plan validation¶
--plan verifies the payload's xxh3-64 hash before decoding. Truncated,
edited, or damaged plans produce an error naming the path:
--plan /mnt/shared/roads.plan: is corrupt: the payload hashes to
1f3c... but the header records 90ab.... The file was truncated, edited, or
damaged in transit — re-create it with --save-plan.
Other errors also name --plan and the path:
- a file that is not a plan
- a foreign format version
- an impossible level count or cluster stride
- an unknown geometry-kind code
- row-indexed sections of disagreeing length
Every run also checks the plan's row domain against the row groups selected
by --bbox and --filter.
A fingerprint records the tylertoo version, thinning flags, and input identities. Mismatches name the changed field:
--plan: saved plan does not match this run: input "roads.parquet" mtime was
"1790242802390408452" when the plan was saved but is "1790244114398193000"
now. Re-run without --plan (add --save-plan to write a fresh one).
Input identity¶
The fingerprint records these fields for every input part:
| Field | Local file | Remote object |
|---|---|---|
| Path / URL | Yes | Yes |
| Byte size | Yes (stat) |
Yes (Content-Length) |
| Row count | Yes (footer) | Yes (footer) |
| Row-group count and pruned selection | Yes | Yes |
| mtime | Yes | No |
| ETag | No | Yes, when the store supplies one |
| Last-modified | See mtime | Recorded for provenance |
Row counts are essential because the winner table is indexed by row position. Replacing an input with fewer or more rows could assign incorrect levels or index out of bounds; footer count checks reject both. Remote objects also bind the plan to their opaque ETag: a changed or missing saved ETag refuses replay, even when size and row structure match. Stores without an ETag produce a warning explaining the weaker size/footer binding. Older plans without a saved ETag retain that weaker binding; save a fresh plan to add it.
The fingerprint checks staleness, not content integrity. cp -p and
rsync -a preserve mtime and size. Remote stores that omit the ETag can
replace objects with the same size and row structure undetected.
The fingerprint deliberately leaves out these flags, so one plan replays
across them: --profile, --row-group-size, --row-group-size-policy,
--full-column-stats, --read-batch-size, --in-flight-batches,
--spill-dir, and --cogp-compat. It covers everything that changes which
features land at which level, and output with --plan is byte-identical
to the run that saved it.
Shared plans for sharded builds¶
Shards need one shared plan because assignment depends on the whole input:
- The density budget water-fills a 128 × GSD super-cell budget over every candidate of a level.
- The level walk carries a running kept count from coarse to fine.
--magnitude-ladderdense-ranks the global distinct values of its column.- The automatic class ranking picks its column from a global vocabulary scan.
Recomputing assignment on each shard would produce inconsistent levels, so
tiles --shard requires --plan. A shard reads only planned row groups
whose bbox reaches its tile range. Validation allows this subset, and the
plan's row tables are remapped to the shorter stream before pass 2. See
Scaling.
Paths and output replacement¶
Both plan paths are checked before scanning. --save-plan needs an existing
writable parent and, if present, a writable file. The plan is written after
assignment; overwriting an existing plan is logged. --plan must be
readable and have valid magic bytes. Future format versions are identified
in the error.
Every subcommand that writes a file
(overview, tiles, export-pmtiles, decode, pyramid, merge, and
shard-plan) refuses an existing output unless given -f/--force. The
GeoParquet writers, overview and decode, build their output in a uniquely
named sibling (OUTPUT.<random>.partial) and rename it over OUTPUT only
after writing the footer. A run killed part-way therefore leaves a previous
output intact. A failed run removes the sibling. After a killed run, delete
it by hand.
Worked scenarios¶
Detail and visibility¶
- Too few coarse features: lower
--point-thinning,--line-thinning, or--polygon-thinning; lower--line-visibilityor--polygon-visibility; or raise--gsd-base. - Disconnected coarse roads: lower
--line-thinningor--line-visibility, keep coalescing on, or raise--gsd-base. - Jagged or over-smoothed geometry: lower
--simplify-factor, for example from 1.0 to 0.5. - Residential roads displace highways: use
--class-rank road_class:…or automatic ranking (leave--no-auto-rankunset). - Coarse levels are too large or slow: raise thinning factors or
--simplify-factor, or lower--gsd-base. - Small buildings vanish early: lower
--polygon-visibilityor use--collapsefor points. - Empty country view of buildings or parcels: use
--polygon-visibility 0 --collapseand a circle layer (dot-fill recipe), or--collapse-squarefor polygons. - Whole map is too sparse or dense: adjust
--gsd-base(up = denser, down = sparser). - Too many mid-zoom features (about z9–z12), or large duplicating files:
raise
--drop-rate.--no-density-dropdisables the budget entirely. - Sparse rural areas disappear under the budget: raise
--drop-gamma. - Dense points look misleadingly sparse: use
--clusterand scale symbols bypoint_count. - Need cluster totals or averages: add
--accumulate-attribute col:sumorcol:meanwith--cluster. - Cell aggregates or prebuilt levels lose features: use
--verbatim.
Remote reads¶
- Huge footer on every query: leave
--full-column-statsunset to suppress string and geometry statistics. - Need row-group skipping on property predicates: enable
--full-column-stats, accepting a larger footer. - Tiny viewports fetch too much: lower
--row-group-sizefor tighter bbox pruning.
Speed and memory¶
- Conversion exhausts RAM or swaps: lower
--read-batch-size, keep streaming enabled, and see Scaling. - Cores remain idle: raise
--in-flight-batchesto process more batches at once. Ifpass2.windows.max_windowinTYLERTOO_PROFILE_JSONstays at 1, reads are the bottleneck: raise--read-workersinstead. --profile speedexhausts RAM: useboundedto spill each level. The defaultautospills when the estimate exceeds its budget.memory allocation of N bytes failedon a musl binary at v0.7.1 or earlier, despite available RAM: use--profile boundedor upgrade. Later binaries ship mimalloc to avoid muslmallocngfragmentation (#480).
corpus/SWEEPS.md
holds the corpus sweeps behind the defaults, including --line-thinning ×
--simplify-factor on Portland roads and the --drop-rate calibration
against tippecanoe.