The table had zero canary rows. Its planner statistics still named the canary.
That is the result of a small PostgreSQL test I built after reading the Anonymizer 3.3 security release. The release describes several fixes, including an issue involving authentic values left in statistics after static masking.[1] I wanted to make the underlying metadata surface visible without pretending to reproduce the extension's vulnerability.
So I used plain PostgreSQL 16.11, a disposable cluster and 100 synthetic rows. No extension. No real personal data. Just a deliberately memorable string: SYNTHETIC_CANARY.
After UPDATE, the current table returned zero matches for that string. pg_stats.most_common_vals still included it. After ANALYZE, that field described the replacement value instead. 🐤
Original figure from the executed synthetic example. This demonstrates ordinary statistics refresh behavior, not an Anonymizer CVE reproduction or secure erasure.
Put a recognizable value in the room
I made the canary common enough to appear in the most-common-values list: 80 rows contained it and 20 contained ordinary.
Run the following only in a new disposable database you own. The table name must be unused. It intentionally disables autovacuum for this one teaching table so automatic statistics refresh does not race the observation; that setting is not a production recommendation.
CREATE TABLE canary_demo (label text) WITH (autovacuum_enabled = false); INSERT INTO canary_demo SELECT CASE WHEN i <= 80 THEN 'SYNTHETIC_CANARY' ELSE 'ordinary' END FROM generate_series(1,100) AS s(i); ANALYZE canary_demo; Here is the inspection query, repeated at each stage:
SELECT most_common_vals::text, most_common_freqs FROM pg_stats WHERE schemaname = 'public' AND tablename = 'canary_demo' AND attname = 'label'; Its first result:
most_common_vals | most_common_freqs {SYNTHETIC_CANARY,ordinary} | {0.8,0.2} Those are actual values, accompanied by their observed frequencies in this tiny example. “Statistics” does not mean the field can contain only an aggregate such as a count. The documented view includes most-common values and can also include histogram boundaries.[2]
Change every row, then ask two different questions
Next I replaced every label:
UPDATE canary_demo SET label = 'masked'; SELECT count(*) FROM canary_demo WHERE label = 'SYNTHETIC_CANARY'; The count was 0. Repeating the statistics query still returned:
{SYNTHETIC_CANARY,ordinary}|{0.8,0.2} Both answers can be true. The row query asks about visible table contents. The statistics query asks about a stored description collected earlier.
That distinction is the entire trap. Checking only the transformed rows demonstrates the transformation's effect on those rows. It does not enumerate every representation of the old data elsewhere in the database system.
The complete SQL script includes the stage labels and both inspections. With psql connected to your own disposable database, it can be run as:
psql -X -A -F '|' -f demo.sql The script sets ON_ERROR_STOP, so an error stops execution rather than letting later output look like a successful sequence. It creates its table directly and leaves it available for inspection in your disposable database. Do not point it at a shared application database.
Refresh the description
Finally:
ANALYZE canary_demo; The statistics query then returned:
{masked}|{1} ANALYZE collects planner statistics from table contents.[3] In this example, refreshing them changed the visible most-common-values list to the replacement label. PostgreSQL normally uses automatic analyze as tables change; deliberately suppressing that for this table made the intermediate state easy to observe.
I ran the example in a separate temporary cluster with no TCP listener, then stopped and removed that cluster. The first attempt found client utilities without a matching server binary; switching to an already installed complete PostgreSQL 16.11 distribution allowed the real run to finish. There is no simulated SQL output in the figure.
Access matters as much as storage
This test inspected statistics as the database owner. It does not demonstrate an unprivileged reader bypassing permissions.
The documented pg_stats view restricts visibility according to table read permissions.[2] If a system introduces masked roles, security views or extension-managed policies, its effective access model needs its own verification. Merely observing a value as an owner is not evidence that every application role can see it.
That is one reason I am keeping the release claim and the mechanism demo separate. Anonymizer's release identifies its affected surfaces and upgrade guidance. My plain SQL test explains how an old value can remain in a statistics description; it does not validate the extension patch, determine affected installations or reproduce the reported exploit.
If you use Anonymizer, follow its release and upgrade instructions rather than treating this toy as a remediation recipe.[1][4]
What I would add to a masking check
For a controlled synthetic test, I would first identify the intended reader role and the surfaces it should be allowed to inspect. Then I would seed distinctive values and check those surfaces after transformation: the visible rows, permitted statistics, derived tables, caches and exports that actually exist in that system.
A negative row query is a useful check. It is simply narrower than a claim that the old value has disappeared everywhere.
Nor does the final ANALYZE result establish secure erasure. This example says nothing about old tuple storage, WAL, replicas, backups, logs or snapshots. Rebuilding a planner description is not a general deletion guarantee.
The smallest useful takeaway is therefore a test question: after changing the rows, which stored descriptions and downstream copies still need inspection? Here, one extra query found the gap immediately.
References and figure
- PostgreSQL Anonymizer 3.3 security release.
- PostgreSQL 16: pg_stats.
- PostgreSQL 16: ANALYZE.
- Anonymizer upgrade documentation.
SQL, synthetic data and the state diagram are original. Counts and labels come from the completed PostgreSQL run.
AI disclosure
AI was used to draft the article.

