004. Reuse PostgreSQL JSON parsing and validation results
- Status: Measured
- Date: 2026-10-13
Problem and mechanism
fromjson() validates a complete JSON document before exposing its HFF value.
The baseline repeated recursive numeric-validation SQL for repeated uses of
the same projected receiver. The candidate caches the fully validated JSON
result by exact receiver SQL text within the current projection scope. Every
call keeps its own presence/no-value expression; callback and shadowed-alias
scopes remain isolated. This is expression reuse, not value common-subexpression
elimination across different SQL expressions.
The validation contract is unchanged. PostgreSQL checks one complete document for JSON syntax, decoded Unicode, and duplicate keys. A recursive walk validates every numeric node against HFF int64 and finite-float rules. An invalid nested number makes the whole parse ordinary null even if later access would not read that field. Boolean, string, and null roots skip the recursive numeric walk; a number root still validates its single number. Array/object recursion does not expand scalar leaf children. The validated result is projected once and reused within its scope.
Applicability
| Execution profile | Applied / Partial / Not applied / Not applicable | Reason |
|---|---|---|
Same projected fromjson() receiver repeated in one projection scope |
Applied | Reuses the fully validated JSON result while keeping per-call presence. |
| One use of a receiver | Partial | No reuse occurs, but scalar leaves are not expanded during recursive validation. |
| Different receiver expressions with equal text content | Not applied | The cache keys SQL expressions, not runtime string values. |
| Separate callback or shadowed-alias scope | Not applied | Projection scopes have isolated cache entries. |
| Boolean, string, or null root | Partial | Syntax, Unicode, and duplicate-key checks remain; the recursive numeric walk is skipped. |
| Number, array, or object root | Partial | Numeric validation remains for the number or all nested numeric values; scalar leaves are not expanded. |
Reproduction
The baseline is f2421d84. The candidate was measured as a source snapshot; its SHA-256 inputs are listed below. Earlier candidate-run timings differed, so this record reports only the final baseline/candidate outputs.
| Candidate source file | SHA-256 |
|---|---|
backends/sql/json.go |
cb69f7ff7719aecca7e276d80d7b2c223bdb7b41022753f948f3cd3928f74c22 |
backends/sql/json_parse.go |
af171c0fb12df43f8a8a517abfd6bd11583054a1d50484fff0a5796937c52fb1 |
backends/sql/json_decode.go |
b50a6bd1ca5c045224e117b91c2c5e5836af3df7e1675de9b3d10e71fbfa481b |
backends/sql/json_parse_bench_test.go |
f32314953483a238d26f82040814477b7411aef8a95de3b9682141d0b9ce7845 |
backends/sql/json_parse_execution_bench_test.go |
c25bba5a9c6f88661f6f9c23b0eed9fd7c790dfdb53256b955caf31a37d8e3a4 |
The host was Darwin 25.2.0, darwin/arm64, Apple M3 Max with 64 GiB RAM. Go
was 1.27.1 and benchmark output used the default GOMAXPROCS=16. PostgreSQL was
18.6 in a Debian aarch64 container using image
sha256:5a5a84b19854a9ffaa54082c166ff4ec27473a361e496e5ea167f298f2da9722. The
container had no explicit CPU, memory, or cpuset limits; its data directory used
tmpfs, so these results do not measure persistent-disk I/O. JIT was disabled,
max_parallel_workers_per_gather was zero, custom plans were forced, and the
statement timeout was 60 seconds.
The same benchmark harness and fixtures were applied to baseline and candidate
source snapshots. The harness used nine query shapes and 100- and 1,000-row
temporary tables: scalar number/string/bool roots, a single object field,
repeated object access, an array predicate, constant valid and invalid-nested
documents, and repeated constants. Exact query shapes are defined by
BenchmarkPostgreSQLJSONParsingBuild in
json_parse_bench_test.go and
BenchmarkPostgreSQLJSONParsingExecution in
json_parse_execution_bench_test.go.
Fixtures included nulls, malformed JSON, duplicate keys, and out-of-range
nested numbers. Before each EXPLAIN, exact SQL result IDs were compared with
local HFF matches. Server planning and execution came from
EXPLAIN (ANALYZE, FORMAT JSON, TIMING OFF, BUFFERS, SETTINGS); fixture setup,
Docker/psql startup, and validation-query time are not reported as database
execution time.
Build measurements:
GOTOOLCHAIN=go1.27.1 go test ./backends/sql -run '^$' -bench '^BenchmarkPostgreSQLJSONParsingBuild$' -benchtime=200ms -count=3
PostgreSQL measurements (use a unique plan directory for each revision):
HFF_POSTGRES_CONTAINER=<container-name-or-id> HFF_POSTGRES_PLAN_DIR=/private/tmp/hff-parsing-plans GOTOOLCHAIN=go1.27.1 go test -tags=integration ./backends/sql -run '^$' -bench '^BenchmarkPostgreSQLJSONParsingExecution$' -benchtime=1x -count=3
Raw baseline/candidate logs, plans, SQL, and summary are outside Git in
/private/tmp/hff-parsing-evidence/ on the measurement host.
Results
Values are medians of three samples. Time ranges show minimum–maximum. Build
B/op is cumulative Go allocation, not peak or retained memory.
| Workload | Baseline ns/op (min–max) | Candidate ns/op (min–max) | B/op | allocs/op | SQL bytes |
|---|---|---|---|---|---|
| ScalarNumber | 21201 ns (21080–23796) | 31930 ns (27149–37502) | 54,882 → 54,927 | 176 → 178 | 4,487 → 4,673 |
| ScalarString | 21206 ns (21184–21420) | 28608 ns (28506–30171) | 55,780 → 55,824 | 179 → 181 | 4,596 → 4,782 |
| ScalarBool | 21130 ns (21115–21133) | 27839 ns (27826–29977) | 55,171 → 55,215 | 171 → 173 | 4,612 → 4,798 |
| ObjectField | 34600 ns (34432–34697) | 46381 ns (45227–46400) | 173,127 → 173,167 | 207 → 209 | 28,957 → 29,143 |
| RepeatedObject | 53252 ns (53212–53758) | 56103 ns (55917–56750) | 273,846 → 215,214 | 416 → 355 | 41,460 → 33,716 |
| ArrayPredicate | 55657 ns (55520–55925) | 67172 ns (60202–67446) | 341,700 → 341,725 | 270 → 272 | 30,002 → 30,188 |
| ConstantObject | 179794 ns (179603–180311) | 184866 ns (182001–211794) | 1,492,110 → 1,492,170 | 443 → 447 | 119,753 → 120,129 |
| ConstantInvalidNested | 180842 ns (179610–180871) | 197330 ns (186629–197456) | 1,492,122 → 1,492,196 | 443 → 447 | 119,753 → 120,129 |
| RepeatedConstants | 344283 ns (337848–353356) | 342103 ns (338851–356236) | 2,963,476 → 2,926,102 | 809 → 783 | 239,556 → 236,157 |
PostgreSQL execution
Planning and execution are reported separately. Matched is the result count
for both baseline and candidate.
| Rows / workload | Baseline planning ms (min–max) | Candidate planning ms (min–max) | Baseline execution ms (min–max) | Candidate execution ms (min–max) | Plan nodes | SQL bytes | Matched |
|---|---|---|---|---|---|---|---|
| 100Rows/ScalarNumber | 0.281 ms (0.262–0.296) | 0.263 ms (0.252–0.263) | 0.492 ms (0.487–0.54) | 0.354 ms (0.348–0.357) | 36 → 36 | 4,487 → 4,673 | 90 |
| 100Rows/ScalarString | 0.289 ms (0.282–0.29) | 0.281 ms (0.258–0.297) | 0.314 ms (0.294–0.342) | 0.19 ms (0.172–0.212) | 36 → 36 | 4,596 → 4,782 | 45 |
| 100Rows/ScalarBool | 0.291 ms (0.245–0.301) | 0.291 ms (0.277–0.415) | 0.292 ms (0.292–0.312) | 0.192 ms (0.174–0.192) | 36 → 36 | 4,612 → 4,798 | 45 |
| 100Rows/ObjectField | 0.901 ms (0.855–0.943) | 0.865 ms (0.846–0.93) | 4.079 ms (4.019–4.292) | 2.73 ms (2.61–2.831) | 132 → 132 | 28,957 → 29,143 | 74 |
| 100Rows/RepeatedObject | 1.573 ms (1.433–1.586) | 1.189 ms (1.126–1.598) | 11.66 ms (11.63–11.92) | 3.256 ms (3.19–3.423) | 220 → 164 | 41,460 → 33,716 | 38 |
| 100Rows/ArrayPredicate | 1.156 ms (1.141–1.238) | 1.197 ms (1.105–1.215) | 1.2 ms (1.094–1.211) | 0.96 ms (0.931–1.005) | 144 → 144 | 30,002 → 30,188 | 77 |
| 100Rows/ConstantObject | 4.066 ms (3.967–4.138) | 4.223 ms (4.014–4.943) | 13.24 ms (12.8–13.34) | 10.38 ms (10.36–10.46) | 499 → 499 | 119,753 → 120,129 | 1 |
| 100Rows/ConstantInvalidNested | 4.037 ms (4.009–4.049) | 4.486 ms (4.077–4.824) | 4.656 ms (4.647–4.827) | 3.521 ms (3.213–3.655) | 499 → 499 | 119,753 → 120,129 | 0 |
| 100Rows/RepeatedConstants | 7.887 ms (7.761–8.616) | 8.404 ms (8.323–10.73) | 2.976 ms (2.788–2.988) | 1.715 ms (1.64–1.846) | 998 → 970 | 239,556 → 236,157 | 90 |
| 1000Rows/ScalarNumber | 0.266 ms (0.246–0.274) | 0.289 ms (0.256–0.293) | 3.827 ms (3.796–4.016) | 2.919 ms (2.873–2.964) | 36 → 36 | 4,487 → 4,673 | 900 |
| 1000Rows/ScalarString | 0.286 ms (0.261–0.296) | 0.288 ms (0.287–0.327) | 2.641 ms (2.611–2.725) | 1.262 ms (1.249–1.276) | 36 → 36 | 4,596 → 4,782 | 450 |
| 1000Rows/ScalarBool | 0.28 ms (0.276–0.284) | 0.274 ms (0.273–0.281) | 2.679 ms (2.653–2.685) | 1.291 ms (1.284–1.317) | 36 → 36 | 4,612 → 4,798 | 450 |
| 1000Rows/ObjectField | 0.84 ms (0.824–0.846) | 1.019 ms (0.998–1.098) | 36.35 ms (36.08–36.57) | 25.09 ms (24.59–25.16) | 132 → 132 | 28,957 → 29,143 | 746 |
| 1000Rows/RepeatedObject | 2.012 ms (1.635–2.245) | 1.205 ms (1.162–1.219) | 112.7 ms (112.6–117.6) | 29.64 ms (28.93–29.81) | 220 → 164 | 41,460 → 33,716 | 368 |
| 1000Rows/ArrayPredicate | 1.263 ms (1.161–1.323) | 1.21 ms (1.089–1.592) | 10.7 ms (10.64–11.08) | 8.319 ms (8.113–8.751) | 144 → 144 | 30,002 → 30,188 | 772 |
| 1000Rows/ConstantObject | 4.437 ms (4.234–4.458) | 4.44 ms (4.386–4.446) | 124.4 ms (124.3–125.9) | 93.6 ms (93.2–94.64) | 499 → 499 | 119,753 → 120,129 | 9 |
| 1000Rows/ConstantInvalidNested | 4.611 ms (4.561–4.75) | 4.644 ms (4.517–4.805) | 42.07 ms (41.38–42.84) | 28.7 ms (28.55–29.11) | 499 → 499 | 119,753 → 120,129 | 0 |
| 1000Rows/RepeatedConstants | 8.967 ms (8.298–9.061) | 8.332 ms (8.275–8.666) | 16.53 ms (15.93–16.62) | 6.541 ms (6.418–6.815) | 998 → 970 | 239,556 → 236,157 | 900 |
Build time increased in eight of nine workload medians. ScalarNumber rose
from 21.201 to 31.930 µs/op (about 51%); RepeatedObject rose from 53.252 to
56.103 µs/op even though its allocation volume and SQL size fell.
RepeatedConstants had a slightly lower Build median. A final baseline and
candidate sample differed from an earlier candidate run, so these Build
figures describe the captured run rather than isolate code-only cost.
Across the sampled PostgreSQL executions, candidate medians were lower for all
nine query shapes at both row counts. The largest difference was
RepeatedObject at 1,000 rows: 112.700 to 29.640 ms/op, SQL 41,460 to 33,716
bytes, and 220 to 164 plan nodes. These measurements describe one local host,
PostgreSQL version, fixture, and three-sample run; they do not predict other
workloads or isolate all run variability.
Correctness and tradeoffs
All benchmark samples matched local HFF result IDs, including malformed
documents and nested invalid numeric values. Full PostgreSQL integration passed
in 268.095 seconds; project formatting, race, vet, parser generation, examples,
WebAssembly, and site checks also passed. PostgreSQL 16+ JSON syntax, Unicode,
and unique-key checks remain in force. Reuse is limited to the exact
SQL receiver expression in a projection scope; each call retains its own
presence state, and callback/shadowed-alias scopes remain isolated. SQL
execution does not enforce local MaxJSONDepth or step budgets. Decoded JSON
strings and object keys must contain no NUL; see the
PostgreSQL fromjson() contract.
Decision and limits
The candidate reduced generated SQL and measured PostgreSQL execution time most clearly for repeated object access, while Build time rose in eight of nine workload medians and SQL grew slightly for several single-use queries. The results do not establish a general speedup or a resource bound. Full document validation remains necessary for every root, including unused nested values, so an invalid document cannot be accepted by pruning its parsed fields. These results do not show that all database work is cached.