Choosing Quadtree Depth for Uneven Point Density

Stop choosing a depth. Choose a leaf capacity, cap the depth as a safety valve, and let the tree be as deep as each region’s density requires. Geographic data is never uniform, so any fixed depth is simultaneously too coarse over cities and absurdly too fine over water — and the whole point of a quadtree is that it does not have to be. This page extends spatial partitioning with quadtree indexes.

Quick Reference

Downstream unit Sensible leaf capacity Reason Primary Use Case
Parquet row group 150k–500k features Gives a row group in the 100 MB band Analytical GeoParquet writes
Distributed join task 50k–200k features Task of a few seconds, cheap to retry Sedona and Spark partitioners
Vector tile at max zoom 2k–20k features Fits a tile’s byte budget Tile archive builds
In-memory R-tree node 16–64 features Classic tree fan-out Per-process spatial indexes
Depth cap (all of the above) 14–18 Stops recursion on coincident points Safety valve, always set

Capacity Instead of Depth

A quadtree subdivides a cell into four children. With a fixed depth d, every cell is subdivided exactly d times regardless of what is in it, so cell area is uniform and cell population is whatever the world happens to put there. On a national address dataset that means a cell over a city centre holds hundreds of thousands of points while a cell over open sea holds none — and every consumer of the partitioning inherits that imbalance.

Capacity-driven subdivision inverts the rule: subdivide a cell only while it holds more than capacity points. Dense regions recurse deeply and sparse regions stop early, so leaf population becomes roughly uniform and leaf area varies by orders of magnitude. That is precisely the right way round, because everything downstream — a row group, a task, a tile — has a budget in items or bytes, not in square kilometres.

The one hazard is unbounded recursion. Points at identical coordinates cannot be separated by subdivision, so a naive capacity rule recurses until floating-point resolution runs out. A depth cap turns that into a bounded, explicit outcome.

Fixed depth gives uniform area; capacity gives uniform population Two grids over the same uneven point distribution. The fixed-depth quadtree divides the whole extent into sixteen equal cells: most are empty or nearly empty, and the one over the dense cluster holds the great majority of the points. The capacity-driven quadtree subdivides only where a cell exceeds its capacity, so the dense region is cut into many small cells while the sparse regions remain single large cells, and every leaf holds a comparable number of points. Leaf population counts are shown for both. Fixed depth 2 — uniform area, wild population 412k 0 1.2k 0 3.1k 8.4k 0 920 14k 2.2k 0 0 640 0 0 one leaf holds 94% of the points every downstream consumer inherits that imbalance Capacity 20k — wild area, uniform population 3.1k 1.2k 15k 19k 17k 18k no leaf exceeds 20k · depth varies 1 to 6 every downstream task gets comparable work

Implementation

python
# Requires: numpy>=1.26, shapely>=2.0  (Python 3.10+)
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np


@dataclass
class Cell:
    xmin: float
    ymin: float
    xmax: float
    ymax: float
    depth: int
    indices: np.ndarray
    children: list["Cell"] = field(default_factory=list)

    @property
    def is_leaf(self) -> bool:
        return not self.children

    @property
    def count(self) -> int:
        return int(self.indices.size)


def build_quadtree(
    xs: np.ndarray,
    ys: np.ndarray,
    *,
    capacity: int = 200_000,
    max_depth: int = 16,
    bounds: tuple[float, float, float, float] | None = None,
) -> Cell:
    """Subdivide on population, not on area, with depth as a safety valve.

    Returns the root cell; leaves are the partitions to hand downstream.
    """
    if xs.shape != ys.shape:
        raise ValueError("xs and ys must have the same shape")
    if xs.size == 0:
        raise ValueError("no points to partition")
    if capacity < 1:
        raise ValueError("capacity must be at least 1")

    if bounds is None:
        bounds = (float(xs.min()), float(ys.min()), float(xs.max()), float(ys.max()))
    root = Cell(*bounds, depth=0, indices=np.arange(xs.size))

    stack = [root]
    while stack:
        cell = stack.pop()
        if cell.count <= capacity or cell.depth >= max_depth:
            # At the cap, an over-capacity leaf is a legitimate outcome: those
            # points are coincident and geometry cannot separate them further.
            continue

        mid_x = 0.5 * (cell.xmin + cell.xmax)
        mid_y = 0.5 * (cell.ymin + cell.ymax)
        px, py = xs[cell.indices], ys[cell.indices]
        left, lower = px < mid_x, py < mid_y

        quadrants = (
            (cell.xmin, cell.ymin, mid_x, mid_y, left & lower),
            (mid_x, cell.ymin, cell.xmax, mid_y, ~left & lower),
            (cell.xmin, mid_y, mid_x, cell.ymax, left & ~lower),
            (mid_x, mid_y, cell.xmax, cell.ymax, ~left & ~lower),
        )
        for x0, y0, x1, y1, mask in quadrants:
            selected = cell.indices[mask]
            if selected.size == 0:
                continue                      # empty quadrants cost nothing
            child = Cell(x0, y0, x1, y1, cell.depth + 1, selected)
            cell.children.append(child)
            stack.append(child)

    return root


def leaves(root: Cell) -> list[Cell]:
    out, stack = [], [root]
    while stack:
        cell = stack.pop()
        if cell.is_leaf:
            out.append(cell)
        else:
            stack.extend(cell.children)
    return out

Validation

The diagnostic is the distribution of leaf populations. A healthy capacity-driven tree has a narrow spread bounded above by the capacity; a long right tail means the depth cap is binding somewhere it should not be.

python
# Requires: numpy>=1.26 — is the partitioning actually balanced?
import numpy as np

sizes = np.array([leaf.count for leaf in leaves(root)])
depths = np.array([leaf.depth for leaf in leaves(root)])

print(f"{sizes.size} leaves · median {np.median(sizes):.0f} · "
      f"p95 {np.percentile(sizes, 95):.0f} · max {sizes.max()}")
print(f"depth range {depths.min()}{depths.max()} · "
      f"{(sizes > CAPACITY).sum()} leaf/leaves over capacity (coincident points)")
print(f"empty leaves: {(sizes == 0).sum()}  (should be zero)")

Expected ranges on a national address dataset at capacity 200,000: median leaf around 120,000, p95 near the capacity, depth spanning roughly 3 to 12, zero empty leaves, and at most a handful of over-capacity leaves corresponding to genuinely coincident points such as flats sharing a building centroid.

Leaf population distribution — the diagnostic that shows whether partitioning worked Two histograms of leaf population. The fixed-depth tree produces a distribution with an enormous spike at zero from empty ocean and farmland cells and a long thin tail extending to hundreds of thousands, meaning most leaves are useless and a few are enormous. The capacity-driven tree produces a distribution concentrated just below the capacity with no empty leaves and a short tail, meaning every leaf is a comparable unit of downstream work. Fixed depth — most leaves empty, a few enormous empty 412k leaf population → 73% of leaves are empty; one holds 412,000 the tree spent its budget on ocean Capacity-driven — concentrated below the cap capacity leaf population → no empty leaves; none over the cap every leaf is a comparable unit of work Why the root extent must be fixed, not derived Two runs of the same partitioner over slightly different inputs. When the root extent is derived from each run\u2019s own data, the cell boundaries shift between runs, so partition identifiers do not mean the same thing and any join that assumes a shared partitioner is wrong. When the root extent is a fixed constant, the grid is identical across runs and partition identifiers are stable and comparable. Bounds from the data — grids disagree run 1 extent run 2 extent — shifted cell 3 means a different place in each run Fixed bounds — grids agree run 1 run 2 cell 3 is the same place in both

Edge Cases and Caveats

Coincident points at the depth cap. Flats in one building often share a centroid, so a leaf can sit above capacity at the cap no matter how deep you allow it to go. That is not a failure of the tree; it is the data saying geometry cannot separate these. Handle it downstream with a secondary key — unit number, feature identifier — rather than by raising the cap.

Bounds derived from the data. Building the root from the data’s own extent means two runs over different inputs produce incompatible trees, which breaks any join that assumes a shared partitioner. Fix the root bounds to the CRS extent or to a documented constant so partition identifiers are stable between runs — the same requirement that Sedona’s shared partitioner imposes.

Choosing capacity from tree aesthetics. A capacity that produces a pleasingly balanced tree is not the goal; a capacity that produces the right downstream unit is. If leaves become Parquet row groups, work backwards from the target row group size and the average bytes per feature.

Frequently Asked Questions

Why is a fixed quadtree depth wrong for geographic data?

Because population, infrastructure, and observation are all wildly uneven, so a depth that gives sensible cell sizes over a city gives millions of empty cells over ocean and farmland, while a depth that suits the countryside leaves urban cells holding a thousand times more points than rural ones. Depth is a property of area; what you actually want to control is a property of content. Subdividing on a leaf capacity does that directly and lets depth vary as the data requires.

What leaf capacity should I choose?

Whatever makes a leaf a sensible unit of the thing it becomes downstream. If each leaf will be written as a Parquet row group, choose a capacity that produces a row group in the hundred-megabyte range. If each leaf becomes one task in a distributed join, choose a capacity that gives a task of a few seconds. The number is set by the consumer of the partitioning, not by the tree.

What happens when many points share exactly the same coordinates?

Subdivision cannot separate them, because every child cell that contains one contains all of them, so a capacity-driven tree recurses until it hits a depth limit or exhausts floating-point resolution. Always cap the depth, and treat a leaf that is over capacity at the cap as a legitimate outcome rather than a bug — it means those points are genuinely coincident and must be handled by a rule other than geometry.


← Back to Spatial Partitioning with Quadtree Indexes