011. Reduce dynamic PostgreSQL JSON sort lowering work

  • Status: Measured
  • Date: 2026-10-13

Problem and mechanism

Dynamic PostgreSQL sorting reconstructed a comparison document even when a native JSON Any list already retained the element representations needed by the sort. Float sort keys also repeated exact numeric decomposition for zero values. The candidate array-gates and consumes the original native JSON list for Any elements. For recoverable dynamic values, it projects each decoded Float locally and uses numeric zero directly as the exact sort key, avoiding unnecessary decomposition for zero; nonzero values still use the shared floatNumeric lowering. JSONB continues to normalize through the comparison-document path when needed to restore numeric subtypes. The schema-resolved homogeneous fast paths and global floatNumeric implementation are unchanged.

These changes were measured together; the results do not isolate the effect of either mechanism. Sorting still rejects mixed runtime families and null or non-orderable elements as ordinary null. Stable ordering and the original representations of equal values are preserved when those representations are recoverable. JSONB type metadata remains necessary when normalization erased an integer/float distinction; a schema-known Float type alone cannot restore a negative-zero sign lost by JSONB.

Applicability

Workload or profile Applied / Partial / Not applied / Not applicable Reason
Native JSON Any list Applied Uses the already-preserved JSON array after the array gate instead of rebuilding a comparison document.
JSONB Any or list-union values Partial Exact Float-key projection applies; comparison-document normalization remains where subtype restoration is required.
Repeated dynamic sorts Applied Both sort operations use the same candidate lowering; the repeated-sort workload is measured separately.
Nested or mixed invalid dynamic lists Partial The candidate keeps the category and applicability checks; it changes the measured SQL construction around them.
Schema-resolved homogeneous Int/Float controls Not applied The existing sequence fast path remains byte-identical.
Local evaluation, arbitrary PostgreSQL plans, and production workloads Not applicable The measurements cover PostgreSQL Build and a controlled local PostgreSQL fixture only.

Reproduction

The baseline is 290e7f6. The candidate source snapshot for backends/sql/json_sort_dynamic.go has SHA-256 cb3e9bc4b514426996e6474db8614115042c985ec8fac299135146c127c3911f. The same benchmark harness was applied to both revisions:

The Build matrix crosses native JSON and JSONB with TypedIntControl, TypedFloatControl, AnyNumbers, AnyStrings, AnyInvalidNested, AnyInvalidMixed, and RepeatedSort. Execution uses those workloads with 25 or 100 rows and 16 or 64 elements per list. Dynamic fixtures include mixed int64/float64 values, strings, signed zero, nested non-orderable values, invalid mixed families, and null rows. Each database sample first checks matching IDs against local HFF before measuring the EXPLAIN ANALYZE query; fixture creation, transport, and validation are not included in the reported execution time.

The host was Darwin 25.2.0, arm64, Apple M3 Max with 64 GiB RAM. Go was 1.27.1 with default GOMAXPROCS=16. PostgreSQL was 18.6 in a Debian aarch64 container using image digest sha256:5a5a84b19854a9ffaa54082c166ff4ec27473a361e496e5ea167f298f2da9722. The container had no explicit CPU, memory, or cpuset limits, and its data directory used tmpfs. The runs used no Docker network, disabled JIT and parallel workers, forced custom plans, and set a 60-second statement timeout.

The final aggregate combines two serialized three-sample rounds in the order baseline, candidate, baseline repeat, candidate repeat (six samples per side). Raw logs, plans, and per-sample results remain outside Git in /private/tmp/hff-sort-evidence/ on the measurement host.

Build command:

GOTOOLCHAIN=go1.27.1 go test ./backends/sql -run '^$' \
  -bench '^BenchmarkPostgreSQLSortBuild$/(json|jsonb)/(TypedIntControl|TypedFloatControl|AnyNumbers|AnyStrings|AnyInvalidNested|AnyInvalidMixed|RepeatedSort)$' \
  -benchmem -benchtime=200ms -count=3

PostgreSQL execution command (use separate plan directories for each revision and repeat):

HFF_POSTGRES_CONTAINER=<container-name-or-id> \
HFF_POSTGRES_PLAN_DIR=/private/tmp/hff-sort-plans-baseline \
GOTOOLCHAIN=go1.27.1 go test -tags=integration ./backends/sql -run '^$' \
  -bench '^BenchmarkPostgreSQLSortExecution$/(json|jsonb)/(25Rows|100Rows)/(16Elements|64Elements)/(TypedIntControl|TypedFloatControl|AnyNumbers|AnyStrings|AnyInvalidNested|AnyInvalidMixed|RepeatedSort)$' \
  -benchtime=1x -count=3

Results

Build time is median ns/op with min–max ranges across six samples. B/op is cumulative allocation, not retained or peak memory. SQL bytes and parameter counts describe the generated predicate.

Format / workload Build ns/op baseline (range) → candidate (range) B/op baseline → candidate allocs/op baseline → candidate Parameters baseline → candidate SQL bytes baseline → candidate
json / TypedIntControl 23,374 (21,537–25,759) → 22,386.5 (21,489–26,373) 32,250 → 32,250 211 → 211 2 → 2 1,415 → 1,415
json / TypedFloatControl 28,747 (26,710–31,982) → 26,755.5 (26,570–26,952) 81,734.5 → 81,734 221 → 221 2 → 2 7,542 → 7,542
json / AnyNumbers 43,421 (40,901–46,615) → 41,048.5 (40,937–41,173) 197,195 → 198,418 250 → 238 2 → 2 31,365 → 30,816
json / AnyStrings 30,261 (28,672–30,877) → 28,109.5 (27,935–29,407) 88,874.5 → 87,009.5 237 → 224 2 → 2 7,253 → 6,704
json / AnyInvalidNested 27,686.5 (26,541–28,897) → 26,372 (26,009–26,685) 82,351.5 → 82,808 202 → 190 0 → 0 6,495 → 5,947
json / AnyInvalidMixed 26,774 (26,717–28,376) → 26,259 (26,114–26,746) 82,353 → 82,807.5 202 → 190 0 → 0 6,495 → 5,947
json / RepeatedSort 51,979 (51,627–56,458) → 50,883 (50,712–51,297) 261,623 → 263,421 337 → 315 2 → 2 37,341 → 36,451
jsonb / TypedIntControl 23,082 (22,702–23,527) → 22,831 (22,598–22,912) 39,233 → 39,233 221 → 221 2 → 2 3,111 → 3,111
jsonb / TypedFloatControl 28,549 (28,070–29,354) → 28,074 (27,742–28,536) 87,969.5 → 87,967.5 231 → 231 2 → 2 9,243 → 9,243
jsonb / AnyNumbers 44,454 (43,905–44,804) → 45,184 (42,242–54,916) 218,293.5 → 202,243 260 → 248 2 → 2 33,094 → 32,533
jsonb / AnyStrings 30,192 (30,081–30,608) → 30,188.5 (29,605–31,441) 96,107 → 96,223 247 → 234 2 → 2 8,982 → 8,421
jsonb / AnyInvalidNested 28,492 (28,453–28,898) → 27,942 (27,637–28,452) 91,650 → 90,719.5 212 → 200 0 → 0 8,224 → 7,663
jsonb / AnyInvalidMixed 28,856 (28,514–29,081) → 27,914.5 (27,518–28,134) 91,649.5 → 90,720 212 → 200 0 → 0 8,224 → 7,663
jsonb / RepeatedSort 52,669 (52,239–54,123) → 52,740.5 (52,473–53,774) 266,288 → 267,244.5 348 → 325 2 → 2 39,070 → 38,167

Database planning and execution are median milliseconds with min–max ranges across six single-execution samples. Plan-node counts, SQL bytes, and expected matched rows are baseline → candidate. The 16 control SQL scripts were byte-identical across the four runs. Across all 16 controls, execution medians ranged from 3.9% lower to 10.5% higher; at 100 rows and 64 elements, control drift was about 1–4%. These six-sample measurements come from one environment without independent replication, so small differences should not be read as stable changes.

Format / rows / elements / workload Planning ms baseline (range) → candidate (range) Execution ms baseline (range) → candidate (range) Plan nodes baseline → candidate SQL bytes baseline → candidate Matched rows
json / 25Rows / 16Elements / TypedIntControl 0.162 (0.124–0.253) → 0.149 (0.124–0.185) 0.387 (0.369–0.471) → 0.427 (0.392–0.474) 19 → 19 1,415 → 1,415 20
json / 25Rows / 16Elements / TypedFloatControl 0.416 (0.348–0.47) → 0.431 (0.394–0.508) 0.95 (0.834–1.026) → 0.941 (0.91–1.335) 45 → 45 7,542 → 7,542 20
json / 25Rows / 16Elements / AnyNumbers 1.048 (0.962–1.098) → 1.076 (0.991–1.469) 1.692 (1.598–2.398) → 1.511 (1.396–1.644) 135 → 133 31,365 → 30,816 20
json / 25Rows / 16Elements / AnyStrings 0.444 (0.387–0.502) → 0.438 (0.379–0.458) 0.519 (0.472–0.586) → 0.463 (0.437–0.527) 43 → 41 7,253 → 6,704 20
json / 25Rows / 16Elements / AnyInvalidNested 0.384 (0.351–0.393) → 0.376 (0.35–0.574) 0.173 (0.164–0.22) → 0.113 (0.102–0.122) 37 → 35 6,495 → 5,947 22
json / 25Rows / 16Elements / AnyInvalidMixed 0.386 (0.342–0.493) → 0.359 (0.32–0.4) 0.256 (0.239–0.313) → 0.237 (0.208–0.252) 37 → 35 6,495 → 5,947 22
json / 25Rows / 16Elements / RepeatedSort 1.255 (1.111–1.366) → 1.288 (1.144–1.742) 3.236 (3.039–3.294) → 2.868 (2.621–3.249) 166 → 162 37,341 → 36,451 20
json / 100Rows / 16Elements / TypedIntControl 0.162 (0.157–0.179) → 0.157 (0.133–0.206) 1.356 (1.262–1.437) → 1.349 (1.309–1.366) 19 → 19 1,415 → 1,415 80
json / 100Rows / 16Elements / TypedFloatControl 0.41 (0.393–0.43) → 0.398 (0.365–0.46) 3.51 (3.423–3.746) → 3.559 (3.441–3.645) 45 → 45 7,542 → 7,542 80
json / 100Rows / 16Elements / AnyNumbers 1.031 (0.975–1.145) → 1.054 (0.975–1.101) 6.471 (6.218–6.665) → 5.646 (5.267–5.914) 135 → 133 31,365 → 30,816 80
json / 100Rows / 16Elements / AnyStrings 0.438 (0.378–0.479) → 0.423 (0.369–0.45) 2.015 (1.852–2.306) → 1.647 (1.605–1.784) 43 → 41 7,253 → 6,704 80
json / 100Rows / 16Elements / AnyInvalidNested 0.379 (0.364–0.4) → 0.374 (0.322–0.417) 0.59 (0.509–0.688) → 0.275 (0.244–0.306) 37 → 35 6,495 → 5,947 86
json / 100Rows / 16Elements / AnyInvalidMixed 0.376 (0.368–0.429) → 0.368 (0.311–0.442) 0.893 (0.869–0.912) → 0.679 (0.627–0.721) 37 → 35 6,495 → 5,947 86
json / 100Rows / 16Elements / RepeatedSort 1.322 (1.192–1.364) → 1.313 (1.27–1.434) 12.09 (11.76–12.29) → 10.595 (10.19–11.02) 166 → 162 37,341 → 36,451 80
json / 25Rows / 64Elements / TypedIntControl 0.14 (0.117–0.17) → 0.155 (0.133–0.232) 1.094 (1.003–1.228) → 1.052 (1.011–1.13) 19 → 19 1,415 → 1,415 20
json / 25Rows / 64Elements / TypedFloatControl 0.421 (0.366–0.539) → 0.377 (0.356–0.426) 3.14 (2.837–3.534) → 3.072 (2.89–3.219) 45 → 45 7,542 → 7,542 20
json / 25Rows / 64Elements / AnyNumbers 1.051 (1.003–1.456) → 1.093 (1.028–1.165) 5.604 (5.363–5.75) → 4.902 (4.652–5.41) 135 → 133 31,365 → 30,816 20
json / 25Rows / 64Elements / AnyStrings 0.418 (0.391–0.489) → 0.427 (0.397–0.448) 1.582 (1.488–1.752) → 1.434 (1.34–1.478) 43 → 41 7,253 → 6,704 20
json / 25Rows / 64Elements / AnyInvalidNested 0.423 (0.338–0.479) → 0.378 (0.334–0.395) 0.553 (0.509–0.631) → 0.221 (0.217–0.272) 37 → 35 6,495 → 5,947 22
json / 25Rows / 64Elements / AnyInvalidMixed 0.368 (0.34–0.39) → 0.374 (0.367–0.38) 0.679 (0.627–0.713) → 0.523 (0.484–0.551) 37 → 35 6,495 → 5,947 22
json / 25Rows / 64Elements / RepeatedSort 1.204 (1.177–1.514) → 1.268 (1.2–1.523) 11.145 (10.52–12.04) → 9.639 (9.4–10.01) 166 → 162 37,341 → 36,451 20
json / 100Rows / 64Elements / TypedIntControl 0.161 (0.129–0.17) → 0.176 (0.16–0.219) 4.223 (4.026–4.57) → 4.278 (4.07–4.414) 19 → 19 1,415 → 1,415 80
json / 100Rows / 64Elements / TypedFloatControl 0.441 (0.399–0.483) → 0.426 (0.366–0.463) 11.78 (11.34–12.52) → 12.04 (11.86–13.76) 45 → 45 7,542 → 7,542 80
json / 100Rows / 64Elements / AnyNumbers 1.158 (0.996–1.41) → 1.1 (1.034–1.423) 22.62 (21.9–23.03) → 19.47 (19.05–19.84) 135 → 133 31,365 → 30,816 80
json / 100Rows / 64Elements / AnyStrings 0.455 (0.398–0.502) → 0.44 (0.392–0.469) 6.225 (5.96–6.473) → 5.466 (5.352–5.884) 43 → 41 7,253 → 6,704 80
json / 100Rows / 64Elements / AnyInvalidNested 0.414 (0.353–0.477) → 0.366 (0.339–0.469) 1.971 (1.888–2.098) → 0.745 (0.697–0.798) 37 → 35 6,495 → 5,947 86
json / 100Rows / 64Elements / AnyInvalidMixed 0.405 (0.379–0.474) → 0.378 (0.348–0.431) 2.649 (2.545–2.796) → 1.9 (1.81–1.93) 37 → 35 6,495 → 5,947 86
json / 100Rows / 64Elements / RepeatedSort 1.374 (1.275–1.802) → 1.267 (1.186–1.31) 43.66 (41.79–46.42) → 38.065 (37.91–38.47) 166 → 162 37,341 → 36,451 80
jsonb / 25Rows / 16Elements / TypedIntControl 0.325 (0.288–0.346) → 0.328 (0.289–0.393) 0.986 (0.919–1.015) → 0.948 (0.907–1.022) 35 → 35 3,111 → 3,111 20
jsonb / 25Rows / 16Elements / TypedFloatControl 0.561 (0.529–0.765) → 0.536 (0.48–0.555) 1.497 (1.416–1.548) → 1.516 (1.438–1.554) 61 → 61 9,243 → 9,243 20
jsonb / 25Rows / 16Elements / AnyNumbers 1.28 (1.17–1.633) → 1.212 (1.166–1.618) 2.34 (2.158–2.691) → 2.242 (2.03–2.334) 151 → 149 33,094 → 32,533 20
jsonb / 25Rows / 16Elements / AnyStrings 0.579 (0.515–0.75) → 0.558 (0.525–0.72) 1.118 (1.027–1.214) → 1.007 (0.944–1.205) 59 → 57 8,982 → 8,421 20
jsonb / 25Rows / 16Elements / AnyInvalidNested 0.505 (0.483–0.545) → 0.557 (0.478–0.706) 1.698 (1.591–1.86) → 1.693 (1.553–1.88) 53 → 51 8,224 → 7,663 22
jsonb / 25Rows / 16Elements / AnyInvalidMixed 0.485 (0.463–0.496) → 0.551 (0.487–0.576) 0.772 (0.746–0.793) → 0.718 (0.664–0.77) 53 → 51 8,224 → 7,663 22
jsonb / 25Rows / 16Elements / RepeatedSort 1.62 (1.518–1.71) → 1.57 (1.446–1.73) 3.679 (3.635–3.949) → 3.442 (3.263–3.884) 182 → 178 39,070 → 38,167 20
jsonb / 100Rows / 16Elements / TypedIntControl 0.316 (0.304–0.404) → 0.358 (0.308–0.46) 3.421 (3.339–3.556) → 3.607 (3.526–3.82) 35 → 35 3,111 → 3,111 80
jsonb / 100Rows / 16Elements / TypedFloatControl 0.552 (0.526–0.651) → 0.598 (0.49–0.754) 5.751 (5.582–6.067) → 5.944 (5.705–6.332) 61 → 61 9,243 → 9,243 80
jsonb / 100Rows / 16Elements / AnyNumbers 1.219 (1.192–1.286) → 1.385 (1.181–1.545) 8.601 (8.241–9.148) → 8.008 (7.813–8.598) 151 → 149 33,094 → 32,533 80
jsonb / 100Rows / 16Elements / AnyStrings 0.579 (0.522–0.679) → 0.655 (0.537–0.79) 4.116 (3.912–4.393) → 3.865 (3.707–4.061) 59 → 57 8,982 → 8,421 80
jsonb / 100Rows / 16Elements / AnyInvalidNested 0.546 (0.535–0.589) → 0.554 (0.449–0.714) 6.553 (6.502–6.82) → 6.4 (6.059–6.756) 53 → 51 8,224 → 7,663 86
jsonb / 100Rows / 16Elements / AnyInvalidMixed 0.533 (0.49–0.674) → 0.489 (0.46–0.719) 2.798 (2.696–2.938) → 2.662 (2.542–2.77) 53 → 51 8,224 → 7,663 86
jsonb / 100Rows / 16Elements / RepeatedSort 1.519 (1.472–1.678) → 1.522 (1.421–2.051) 14.225 (13.55–14.92) → 12.825 (12.56–13.33) 182 → 178 39,070 → 38,167 80
jsonb / 25Rows / 64Elements / TypedIntControl 0.317 (0.28–0.331) → 0.337 (0.269–0.396) 3.734 (3.652–3.883) → 3.779 (3.708–3.988) 35 → 35 3,111 → 3,111 20
jsonb / 25Rows / 64Elements / TypedFloatControl 0.538 (0.503–0.65) → 0.585 (0.546–0.739) 5.794 (5.568–5.927) → 5.855 (5.634–6.001) 61 → 61 9,243 → 9,243 20
jsonb / 25Rows / 64Elements / AnyNumbers 1.217 (1.087–1.604) → 1.224 (1.104–1.456) 8.568 (8.425–9.012) → 7.966 (7.82–8.561) 151 → 149 33,094 → 32,533 20
jsonb / 25Rows / 64Elements / AnyStrings 0.564 (0.502–0.658) → 0.546 (0.502–0.607) 3.216 (3.123–3.369) → 3.157 (3.069–3.315) 59 → 57 8,982 → 8,421 20
jsonb / 25Rows / 64Elements / AnyInvalidNested 0.536 (0.48–0.656) → 0.498 (0.485–0.614) 8.329 (7.914–8.408) → 8.108 (7.88–8.449) 53 → 51 8,224 → 7,663 22
jsonb / 25Rows / 64Elements / AnyInvalidMixed 0.515 (0.475–0.578) → 0.524 (0.475–0.703) 3.128 (3.073–3.364) → 2.986 (2.895–3.215) 53 → 51 8,224 → 7,663 22
jsonb / 25Rows / 64Elements / RepeatedSort 1.528 (1.447–1.701) → 1.683 (1.496–1.952) 14.005 (13.5–14.23) → 12.455 (12.21–13.02) 182 → 178 39,070 → 38,167 20
jsonb / 100Rows / 64Elements / TypedIntControl 0.306 (0.292–0.325) → 0.319 (0.304–0.398) 15.255 (15.06–15.78) → 15.805 (15.37–16.22) 35 → 35 3,111 → 3,111 80
jsonb / 100Rows / 64Elements / TypedFloatControl 0.558 (0.533–0.684) → 0.605 (0.537–0.652) 23.31 (22.32–24.4) → 23.805 (23.51–24.39) 61 → 61 9,243 → 9,243 80
jsonb / 100Rows / 64Elements / AnyNumbers 1.178 (1.134–1.214) → 1.381 (1.215–1.643) 33.81 (33.07–35.07) → 31.955 (31.13–32.67) 151 → 149 33,094 → 32,533 80
jsonb / 100Rows / 64Elements / AnyStrings 0.571 (0.515–0.599) → 0.595 (0.536–0.669) 13.005 (12.79–13.33) → 12.58 (12.18–13.06) 59 → 57 8,982 → 8,421 80
jsonb / 100Rows / 64Elements / AnyInvalidNested 0.542 (0.488–0.631) → 0.555 (0.48–0.682) 32.125 (30.38–33.21) → 32.08 (31.75–32.67) 53 → 51 8,224 → 7,663 86
jsonb / 100Rows / 64Elements / AnyInvalidMixed 0.52 (0.498–0.583) → 0.565 (0.495–0.589) 12.405 (12–12.63) → 12.04 (11.6–12.24) 53 → 51 8,224 → 7,663 86
jsonb / 100Rows / 64Elements / RepeatedSort 1.607 (1.439–1.742) → 1.512 (1.461–2.119) 55.85 (55.02–57.45) → 49.97 (49.22–50.51) 182 → 178 39,070 → 38,167 80

At 100 rows and 64 elements, the measured JSON AnyNumbers, AnyStrings, and RepeatedSort execution medians changed from 22.62 to 19.47 ms, 6.225 to 5.466 ms, and 43.66 to 38.065 ms. The corresponding JSONB metadata-backed medians were 33.81 to 31.955 ms for AnyNumbers and 55.85 to 49.97 ms for RepeatedSort; the JSONB AnyInvalidNested workload was effectively unchanged at 32.125 versus 32.08 ms. These are workload-specific observations, not a general execution guarantee. Build timing was not uniformly lower, some candidate workloads allocated more bytes despite fewer allocations, and control timings drifted despite identical SQL. The benchmark validates predicate results and plan cardinality; it does not claim to compare every sorted output value. Focused PostgreSQL/local parity tests cover selected sort values, subtypes, stable ties, JSON whitespace and escapes, underflow-to-zero, repeated sorts, and invalid shapes.

Correctness and tradeoffs

The PostgreSQL/local dynamic-sort parity matrix passed 2,312 comparisons. The project check and integration-tagged SQL vet passed. The correctness tests include JSONB metadata restoration and confirm that ordinary JSONB Float schema information cannot recover a negative-zero sign that storage already erased. The combined candidate reduces SQL bytes and plan-node counts for measured dynamic workloads while retaining normalization where JSONB representation recovery needs it. It does not alter HFF collection semantics or the schema- resolved homogeneous path. No retained-memory, peak-memory, production, or cross-version measurement was made.

Decision and limits

Retain the bounded lowering for the measured dynamic JSON sort shapes. The measured improvements vary by JSON format and workload; the JSONB invalid-nested case and typed controls do not show a consistent execution benefit. Do not extrapolate these local measurements to other schemas, PostgreSQL versions, list sizes, data distributions, or service latency. Further optimization of dynamic sort construction requires its own paired correctness and measurement evidence. See the PostgreSQL optimization catalog for the translation scope.

Explore HFF
Search documentation

Search the language specification and guides.