Splitting space with quadtrees
Open in the interactive app: practice, quiz, mock interview →In one minute
- A quadtree recursively splits a 2D region into four equal quadrants, but only where there is data. Empty ocean stays one big node; Manhattan becomes many small ones.
- Each leaf holds up to a fixed number of points (the capacity, often 50 to 500). When a leaf overflows it splits into four children and pushes its points down.
- Range and nearest-neighbour queries walk only the nodes whose boxes intersect the search area, so cost tracks local density rather than total data size.
- It is usually an in-memory structure inside a service, rebuilt or updated from a durable store. That makes it fast and adaptive, but you own its consistency, memory, and rebuild time.
How it works
Structure
Every node owns an axis-aligned box. A leaf stores a list of points. An internal node stores no points, only four children: north-west, north-east, south-west, south-east, each covering exactly a quarter of the parent. The root covers the whole domain, for example the world in latitude and longitude, or a projected map in metres.
This is the point-region (PR) quadtree, the variant most location services use. Splits happen at the box centre, not at a data point, so the shape depends only on which points exist, not on insertion order. The original quadtree of Finkel and Bentley split at each inserted point instead, so its shape did depend on order (Finkel and Bentley, 1974); Samet's survey names and compares the variants (Samet, 1984).
Insert
Descend to the leaf whose box contains the point and append it. If the leaf now exceeds capacity, create four children and push its points down. A maximum depth is essential: 600 points with identical coordinates (a stadium, a warehouse) can never be separated, so without a cap you recurse until the stack dies. At max depth a leaf simply grows.
Range query
Start at the root. Skip any subtree whose box misses the rectangle, test points in leaves, and recurse into internal nodes. Pruning is what makes this fast: a 2 km search in a planet-wide tree touches a few dozen nodes.
Nearest neighbours
For k-nearest, keep a priority queue keyed by the minimum distance from the query point to each node's box. Pop the closest entry; a leaf pushes its points with exact distances, an internal node pushes its children. Stop after popping k points. This "distance browsing" algorithm works for any hierarchical spatial index, R-trees included, and can return neighbours one at a time without fixing k in advance (Hjaltason and Samet, 1999). This best-first search visits the fewest nodes and handles nearest points in a neighbouring quadrant, where grid and geohash approaches stumble.
Delete and update
Deletion is a leaf operation; when four siblings together hold fewer than the capacity, merge them back into one leaf. A moving object is delete plus insert, but most updates stay in the same leaf and can be done in place.
Depth and memory
With N points and capacity C there are roughly N / C leaves, and depth grows with the density contrast between sparse and dense regions. Twenty levels of a world quadtree reach cells about 38 m wide at the equator, which is exactly the width of a level-20 web map tile (Bing Maps tile system).
Down the stack
Layer 1The contract
A range query returns exactly the points inside the rectangle, and a best-first k-nearest search returns the true k nearest points. Both guarantees hold only for the snapshot the query reads. Because the tree usually lives in a service's memory, freshness is whatever the update path provides: "changes visible within 10 minutes" for snapshot rebuilds, or seconds with a change stream. Memory use is roughly linear in the number of points, and query time grows with local density, not total size.
Layer 2The mechanism
d3-quadtree stores internal nodes as sparse 4-element arrays of children and leaves as small objects holding a datum. Coincident points are chained in a linked list through a next field instead of splitting forever, which is an alternative to a depth cap (d3-quadtree docs). Its visit callback walks nodes in pre-order and lets the caller prune a subtree by returning true, which is how range and nearest-neighbour searches are built on it.
Lucene's spatial module took a different route with QuadPrefixTree: each quadtree cell becomes a string token, and shapes are indexed as the set of cell tokens that cover them, so an inverted index can answer spatial queries. Elasticsearch used this for geo_shape (21 levels by default, 29 at most) but deprecated prefix trees in 6.6 because of the precision trade-off; shapes are now split into triangles and stored in a BKD tree (Elasticsearch 7.x docs). Older guides that tell you to tune tree_levels are describing the legacy path.
A linear quadtree stores only leaves, keyed by their Morton code, in a B-tree. Web map tiles are the best-known case: a Bing "quadkey" interleaves the tile's X and Y bits into base-4 digits, its length is the zoom level, and a tile's key starts with its parent's key, so tiles sit in an ordinary B-tree index (Bing Maps tile system). Postgres also ships a disk-based quadtree: SP-GiST's default operator class for points, quad_point_ops, which supports <-> nearest-neighbour ordering (Postgres docs).
Layer 3The machine
A pointer-based quadtree is a chain of dependent loads. Each level needs the child pointer before it can fetch the child, so a 20-level descent that misses cache costs about 20 times 80 to 100 ns, near 2 us. In practice the top 10 levels stay hot in L2 and L3 cache, and only the last few levels miss.
Node layout matters. Four 8-byte child pointers plus a 32-byte box is 64 bytes, one cache line, before allocator headers. Storing nodes in one flat array with 32-bit child indices halves the pointer size and keeps siblings adjacent. In garbage-collected runtimes, a snapshot rebuild that allocates half a million node objects every 10 minutes pushes the old generation hard; arrays of primitives avoid most of that. The swap itself is a single atomic reference store, and readers on the old tree finish undisturbed.
Why it's built this way
- Point density varies by orders of magnitude between oceans and city centres, so cells must adapt instead of being fixed.
- Adaptive cells mean a pointer-linked tree, which is natural in memory and awkward in a database.
- In-memory state must be rebuilt and replicated, so services use snapshot builds and atomic swaps.
- Pointer chasing is slow on modern CPUs, so fast implementations flatten the tree into arrays or Morton-ordered leaves.
Diagram
flowchart TD
I["Insert point P"] --> D["Descend to child containing P"]
D --> L{"Leaf?"}
L -->|no| D
L -->|yes| A["Append P to leaf"]
A --> O{"Count > capacity and depth < max?"}
O -->|no| Done["Done"]
O -->|yes| S["Create NW, NE, SW, SE children"]
S --> R["Move leaf points into children"]
R --> DoneWorked example
A local-search service indexes 40 million businesses worldwide and answers "top 20 near me" at 15,000 QPS.
With capacity 100, the tree has around 400,000 leaves and about 530,000 nodes in total. Each point is an 8-byte ID plus two 8-byte floats, so 24 bytes; 40 million points is just under 1 GB, and node overhead adds perhaps 50 MB. That fits in the memory of a single serving host, so each replica holds the whole tree and queries never cross the network.
A KNN query for 20 results visits 6 to 15 leaves and computes up to 1,500 exact distances, 20 to 50 microseconds on one core. At 15,000 QPS the index needs under one core; serialisation, ranking, and the network cost more.
The hard part is freshness. Businesses change at about 50 updates per second. Two common designs:
- Rebuild the tree from a snapshot every 10 minutes on a background thread, then swap a pointer. Building 40 million points takes about 30 seconds on one core. Simple and lock-free for readers, but changes take up to 10 minutes to appear.
- Apply a change stream directly with fine-grained locks per leaf. Fresh within seconds, but harder to test and to reason about under concurrent split and merge.
Most teams start with snapshot-and-swap and add an overlay of recent changes if they need faster freshness.
In the wild
- Elastic, 2019: quadtree cells give way to triangles. Elasticsearch had indexed
geo_shapefields as quadtree or geohash prefix-tree cells, and tuning tree depth against index size was hard enough that some users never found a workable setting. From 7.0 the default became a triangle mesh in a BKD tree, with 1 cm accuracy and several times faster indexing; in one development test a polygon became 8 triangle terms instead of 1,105,889 quad cells (Elastic). Covering shapes with fixed cells is the weak spot of cell-based quadtree indexes. - Uber, 2016: no spatial tree at all. Uber's geofence lookup service, its highest-QPS service at the time, chose not to index fences with an R-tree or S2. Because the business is city-centric, it first scans city geofences, then only that city's fences, cutting the search from tens of thousands of polygons to hundreds. On New Year's Eve 2015 it served 170,000 QPS on 40 machines at 35% CPU, under 5 ms at p95 (Uber). Check whether a natural coarse partition already does the tree's job.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| PR quadtree in memory | Adapts to density, fast KNN, simple code | You own build, memory, replication, and freshness | Read-heavy, data fits in RAM, density varies a lot |
| Geohash index in a database | No custom service, durable, easy to shard | Fixed cell size, edge effects, more candidates in dense areas | Moderate scale, simple radius queries |
| R-tree (PostGIS, SQLite) | Indexes shapes, not just points; disk-backed | Overlapping boxes, write cost, database-bound | Polygons and lines, or data larger than RAM |
| Fixed grid | Trivial, O(1) cell lookup | Wastes memory in empty areas, overloads dense cells | Bounded area with fairly uniform density |
Pitfalls in production
- No depth cap. Duplicate coordinates cause infinite splitting. Cap depth at 20 to 25 and let deep leaves exceed capacity.
- Using degrees as if they were metres. A box that is 0.01 degrees square is 1.1 km wide at the equator and 0.4 km wide in Helsinki. Compute distances with haversine, or build the tree on a projected plane.
- Antimeridian blindness. A search near 180 degrees longitude must wrap. Split the query rectangle into two when it crosses the line.
- Long rebuilds blocking reads. Building under a global lock stalls queries for seconds. Build a new tree off to the side and swap an atomic reference.
Leadership lens
- Ask whether the data fits in RAM on one host with headroom for two copies during a swap. If not, you are designing a sharded spatial service, which is a much bigger project.
- Decide freshness explicitly. "Within 10 minutes" and "within 2 seconds" lead to very different designs and on-call burdens.
- Track leaf count, max depth, and points-per-leaf as metrics. A sudden jump in max depth usually means bad input data, such as millions of points at (0, 0).
Check yourself
Why does a PR quadtree's shape not depend on insertion order?
Show answer
It always splits at the geometric centre of a box, so the final set of leaves is determined only by which points exist, not by the order they arrived.
A range query near a quadrant boundary returns correct results without any neighbour logic. Why, when geohash needs 8 neighbours?
Show answer
The query tests box intersection at every level, so every node that overlaps the search rectangle is visited regardless of which side of a boundary it is on.
What breaks if you choose a capacity of 1?
Show answer
The tree becomes very deep with many tiny nodes, pointer-chasing dominates query time, memory overhead per point explodes, and duplicate points can only be handled by the depth cap.
Sources and further study
Primary
- Finkel and Bentley, Quad Trees: A Data Structure for Retrieval on Composite Keys (Acta Informatica, 1974): the original point quadtree.
- Samet, The Quadtree and Related Hierarchical Data Structures (ACM Computing Surveys, 1984): the standard survey of point, PR and region quadtrees.
- Hjaltason and Samet, Distance Browsing in Spatial Databases (ACM TODS, 1999): the best-first nearest-neighbour algorithm used on quadtrees and R-trees.
Systems docs
- PostgreSQL: SP-GiST indexes: a disk-based quadtree and k-d tree inside a general-purpose database.
- Bing Maps tile system: quadkeys as a linear quadtree for map tiles, with the resolution of every level.
- Lucene QuadPrefixTree: the cell-token approach that search engines used before BKD trees.
Books
- System Design Interview, Volume 2, Alex Xu and Sahn Lam, chapter 1 "Proximity Service": an in-memory quadtree built at server start-up, with memory and rebuild estimates.
- Designing Data-Intensive Applications (2nd ed.), Kleppmann and Riccomini, chapter 4 "Storage and Retrieval": multidimensional indexes and why ordinary B-trees cannot answer 2D ranges alone.
Other curricula
- donnemartin/system-design-primer: has no quadtree or geospatial-index section; it treats location only as a way to shard users.
Watch
- Designing a location database: QuadTrees and Hilbert Curves (Gaurav Sen): quadtrees in a location service and how space-filling curves relate.
- Proximity Search & Geospatial Indexes Explained (Hello Interview): where quadtrees sit next to geohash and R-trees in a design.
Free resources
- Wikipedia: Quadtree: the variants (point, PR, region) and their complexity.
- Quadtree visualisation (Jim Kang): an interactive view of splits as points are added.
- d3-quadtree: a compact, well-documented production implementation with a readable API.
- Wikipedia: Z-order curve: how a quadtree's leaves map to a one-dimensional key order.