fix: apply sortby when building vector tiles - #275
Merged
Conversation
`Collection.get_tile()` accepts a `sortby` argument and never uses it, so `?sortby=` has no effect on the order of features inside an MVT. Feature order is the only draw-order control a renderer like deck.gl has, so overlapping polygons render in whatever order the query plan produced. Adds two tests, both failing: - `test_tile_sortby` — `sortby=-ogc_fid` returns features in physical order (601, 602, 603, ...) rather than descending. The ascending case passes only because physical order happens to match it, which is why the descending assertion is the one that matters. - `test_tile_sortby_invalid_column` — an unknown sort column returns 200 on /tiles because the value is never parsed; /items returns 404.
Adds the ORDER BY clause `get_tile()` was already parsing everywhere else, positioned before LIMIT so `?sortby=` controls both the order features appear in the tile and, when a tile exceeds `tipg_max_features_per_tile`, which features survive truncation. The latter was previously arbitrary: LIMIT with no ORDER BY picks whatever subset the plan happens to produce. Applied unconditionally. With no `sortby`, `_sortby()` falls back to the collection's primary key, matching /items and making the default path deterministic too. An unknown sort column now raises InvalidPropertyName (404) on /tiles, as it already did on /items, instead of being silently ignored.
An ORDER BY in the CTE makes LIMIT truncation deterministic but does not guarantee the order rows reach the aggregate: a CTE has not been an optimisation fence since PG12, so it can be inlined and a parallel plan may reorder freely. PostgreSQL only guarantees aggregate input order when the ORDER BY is inside the aggregate call, so the previous commit alone can pass today and silently regress under a different plan or a larger table. Splits the ORDER BY builder into `_sort_expressions()`, which returns (column, SQL fragment) pairs, so `get_tile()` can reuse the parsed sort for both the CTE and the aggregate. `_sortby()` is now a thin wrapper and its callers are unchanged. The aggregate can only order by columns of `t`, so an explicit `sortby` naming a column that `properties` filters out is now rejected with 404 rather than silently unsorted. Only an explicit `sortby` adds the aggregate ORDER BY; the primary-key default keeps the CTE ordering alone, so existing `properties`-restricted requests are unaffected.
The parameter was already listed for /tiles but described as if it only sorted a feature list. Says what it actually controls — draw order within the tile and which features survive `limit` truncation — and documents that the sort column has to be in `properties` when that is set.
slesaad
marked this pull request as ready for review
September 2, 2026 18:31
vincentsarago
approved these changes
Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Collection.get_tile()accepts asortbyargument and never uses it, so?sortby=has no effect on vector tiles./itemssorts correctly;/tilessilently drops the parameter. Feature order inside an MVT is the only draw-order control renderers such as deck.gl have, so overlapping polygons stack arbitrarily. Present in every released version I checked (1.0.0 through 1.6.0).Decisions
ORDER BY goes before
LIMIT. Beyond fixing feature order, this makes truncation deterministic —LIMITwith noORDER BYcurrently keeps an arbitrary subset once a tile exceedstipg_max_features_per_tile.Applied unconditionally, not only when
sortbyis passed. With nosortby,_sortby()already falls back to the primary key, so this matches/items. It adds a sort to the default path in exchange for deterministic truncation. Happy to gate it onsortbyif you would rather not pay that.Ordering is repeated inside the
ST_AsMVTaggregate. PostgreSQL only guarantees the order rows reach an aggregate when theORDER BYis inside the aggregate call. The CTE-levelORDER BYdoes work today, because theLIMITforces the sort to be kept — but that protection is incidental, and the failure mode is a silent200with wrongly stacked features. To be clear about how strong this is: I could not construct a plan that actually reorders, so this is insurance, not an observed fix. No measurable cost (timings overlap fully).Only an explicit
sortbyadds the aggregateORDER BY. The primary-key default keeps the CTE ordering alone, so existingproperties=-restricted requests are unaffected.A sort column excluded by
properties=is rejected rather than silently unsorted. The aggregate can only order by columns present in its input row. The alternative — quietly widening the projection — would return a property the caller explicitly excluded. RaisesInvalidPropertyName(404), consistent with how/itemstreats an unknown column._sortby()split into_sort_expressions(), which returns(column, SQL fragment)pairs soget_tile()can reuse one parse for the CTE, the aggregate, and the projection check._sortby()is now a thin wrapper; its existing callers are unchanged.Behaviour changes
/tiles:200(ignored) →404, matching/items?properties=x&sortby=ywhereyis not inx:200unsorted →404sortby;LIMITtruncation is deterministicTesting
Four tests, each watched fail against
mainfirst. Full suite passes (106 → 108).Also verified against real data (NOAA HMS smoke polygons, 932 features, three overlapping density classes). Counting features drawn after something heavier was already down, on one tile of 257 features:
sortbysortby=density_rankNote
This supports the AIR4US work for visualizing HMS smoke data. The vector tiles have density property - denser features should be drawn on top of overlapping less dense features. Even though
sortbyis advertised as a query param for tiles, it isn't honored.