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.

Top comments (4)
The row query versus pg_stats split is a nice way to show it. One practical addition: the same leftover shows up in places people rarely think about, like pg_stats histogram_bounds for ordered columns and extended statistics created with CREATE STATISTICS. Also, a plain autovacuum ANALYZE only refreshes the sampled stats, so after masking or deleting sensitive data I'd run an explicit ANALYZE on the table and then check the same pg_stats view for the old values. Did you try this on a column with a histogram rather than just most-common-values?
Yep, Iโd only checked the most-common values in the original example. I tested it again on PostgreSQL 16.11 with 1,000 distinct synthetic values. After the update, both
histogram_boundsand the multivariate MCV list inpg_stats_extstill had the old values. RunningANALYZEon the whole table refreshed both, but running it on just one column left the two-column stats unchanged.The delete case was interesting too. Even after deleting all rows and running
ANALYZE, the old histogram was still there. Turns out PostgreSQL keeps the existing stats when the table is completely empty.So yeah, checking those other stats was useful. Iโd just be careful saying โrun ANALYZE and itโs gone,โ since sampling and empty tables can still leave old stats behind.
Thanks for retesting this on 16.11, the result that ANALYZE on a single column leaves the two-column extended stats untouched is a useful detail. The empty-table behaviour is a good reminder too. We'll soften the wording to say that ANALYZE on the whole table refreshes the stats, but sampling and empty tables can still leave old values behind, and recommend checking pg_stats and pg_stats_ext afterwards.
์์ ์๋ก ๊ด์ฐฐํ ์์ฌ ํต๊ณ๋ฅผ ์ผ๋ฐ ์ฌ์ฉ์ ๊ถํ ์ฐํ๋ ์์ ํ ์ญ์ ์ ์ฆ๊ฑฐ๋ก ํ๋ํ์ง ์์ ์ค๋ช ์ด ์ข์์ต๋๋ค. ๊ฒ์ฌํ๋ฅผ โ์ฝ๋ ์ญํ ร ํ์ธํ ํ๋ฉด ร ๋ณํ ๋จ๊ณโ๋ก ๋๋ ๋๋ฉด, ํ ์กฐํ๋ง ํต๊ณผํ ๊ฒฐ๊ณผ๋ฅผ ์ ์ฒด ์๊ฑฐ๋ก ์คํดํ์ง ์๋๋ก ๋์ธ ์ ์๊ฒ ๋ค์. ๋๊ธ์์ ์ถ๊ฐ๋ ๋น ํ ์ด๋ธ ์ฌ๋ก๋ ๋ณ๋ ์กฐ๊ฑด์ผ๋ก ๋จ๊ฒจ์ผ ํ ์ด์ ๊ฐ ๋ถ๋ช ํด์ก์ต๋๋ค.