Postgres Collation Version Mismatch in Docker: Is Your Data Safe?

devopsdockerpostgresqlself-hosting
Postgres Collation Version Mismatch in Docker: Is Your Data Safe?

It depends on your locale and provider: on the default en_US.utf8, moving from 17-bookworm to 17-trixie prints the warning, and nothing moved in my tests. The same tag change on ICU, or a move from Alpine, returned wrong rows and let duplicates through a unique index. Check yours with pg_amcheck, then REINDEX, then refresh.

I ran this on November 7, 2026, with PostgreSQL 17.11 images on Docker Desktop 29.6.1 under WSL2, not on a real VPS. The test table was customers, 1,000,000 rows with a unique index on email and text indexes on name and (city, name). I didn't test partition bounds, CHECK constraints, generated columns, or materialized views, and of the glibc locales that changed, only lv_LV ran against real indexes.

Is Your Data at Risk?

An index stores rows in the order the collation gave when they were inserted. If the new library compares some strings differently, a lookup walks the tree the new way and misses rows that are there. The unique check walks the same tree, so a duplicate can get in.

This is what happened on each change, on the same volume:

ChangeWarningamcheckResult
17-bookworm to 17-trixie, default en_US.utf82.36 to 2.41PassedNo order change found, duplicates rejected
Same tags, ICU, straight and curly apostrophes in one index153.120 to 153.128FailedApostrophe rows missing from lookups, duplicates inserted
Same tags, ICU, one apostrophe shape or none in a table153.120 to 153.128PassedNo missed lookups
Same tags, lv_LV added as the image README shows2.36 to 2.41FailedEmail lookups missed 649 of 2,000, and 649 of 2,000 duplicates inserted
17-alpine to 17-trixieNoneFailedEmail lookups missed 1,096 of 2,000, and 1,100 of 2,000 duplicates inserted
Builtin provider or C locale, bookworm to trixieNonePassedNo recorded version moves with the OS

I tested duplicates by inserting existing emails a second time, and those counts depend on my seed data, so they aren't rates.

On en_US.utf8 I looked hard for a change and found none. The en_US collation section has the same hash on both images, and sort inside each image agreed on 376,400 lines of names, emails, and real text, as did the server's ORDER BY.

The test can see a change, because lv_LV moved 3,291 of those lines and th_TH moved 107,800. LC_COLLATE also differs for ber_DZ, cv_RU, and kab_DZ.

The en_US result covers 2.36 to 2.41, and bullseye's 2.31 gave the same answer. I found no change, but I can't rule one out for your strings, and the amcheck docs say it can show corruption exists but can't show there is none.

Postgres treats the C library's version as a proxy for the collation version, and the Postgres wiki still advises reindexing after a glibc upgrade. The wiki names glibc 2.28 as the major break, which I couldn't reach on current tags.

ICU moved with the OS, from ICU 72 to ICU 76, and it changed how the two apostrophes sort:

O Aaron | O-Aaron | O'Aaron | O'Zed | O’Aaron | O’Zed | OAaron
O Aaron | O-Aaron | O'Aaron | O’Aaron | O'Zed | O’Zed | OAaron

A table with only 1% straight and 0.1% curly apostrophes still failed amcheck, and retrying its apostrophe rows inserted 295 duplicates. Its ordinary rows inserted none. I only tried apostrophes on real indexes, and 46,302 of 551,368 test strings moved in a wider comparison, so a pass on my tables doesn't clear other characters.

Alpine is the worst case, because it broke the most and nothing warned me. It records no collation version, and its en_US.utf8 looks like byte order in my output, so this index range found nothing:

index  name >= 'a' AND name < 'b'|0
table  name >= 'a' AND name < 'b'|134328

What Does the Warning Mean?

Postgres records the library's collation version when it creates a database, then compares it with the running library on every connection:

WARNING:  database "shop" has a collation version mismatch
DETAIL:  The database was created using collation version 2.36, but the operating system provides version 2.41.
HINT:  Rebuild all objects in this database that use the default collation and run ALTER DATABASE shop REFRESH COLLATION VERSION, or build PostgreSQL with the right library version.

You usually see it with no Postgres upgrade at all. A maintainer attributes it to the images moving to Debian trixie in this issue, because tags without a Debian name follow the newest release.

Compare recorded and actual versions for every database:

docker compose exec -T postgres psql -U postgres -d postgres -Atc "SELECT datname, datlocprovider, datcollate, datcollversion, pg_database_collation_actual_version(oid) FROM pg_database ORDER BY 1;"
postgres|c|en_US.utf8|2.36|2.41
shop|c|en_US.utf8|2.36|2.41
template0|c|en_US.utf8||2.41
template1|c|en_US.utf8|2.36|2.41

The second column is the provider: c is the C library, i is ICU, b is builtin. An ICU database still shows en_US.utf8 in the third column, so go by the second. Apart from template0, an empty recorded version next to a real actual version is what my Alpine volume looked like.

The mismatch costs you something even when the sort didn't change. createdb fails with template database "template1" has a collation version mismatch, and CREATE DATABASE appdb TEMPLATE template0; is the workaround. Every connection logs the warning too, so a pg_isready healthcheck at 1 second added 30 warnings in 30 idle seconds, at three log lines each.

How Do You Check Your Own Indexes?

Run this before you change anything, once per database:

docker compose exec -T postgres pg_amcheck -U postgres -d shop --install-missing --heapallindexed

It keeps going past a failing index, lists each one, and exits 2 if anything failed. --install-missing creates the amcheck extension, so it's a small schema change. Leave --parent-check off on a live server, because per the docs it takes a ShareLock that blocks writes.

A damaged index reports item order invariant violated. A clean run means amcheck found nothing, and the rebuild below is still the way to clear the warning.

How Do You Fix It Safely?

Plain REINDEX blocks the table while its indexes rebuild. On 4,000,000 rows with a 500 ms lock_timeout, most reads and writes timed out during the rebuild, and only ones landing as it ended succeeded. On a server taking traffic, use REINDEX DATABASE CONCURRENTLY shop; in place of the plain form below, because with the concurrent form on that table all 6 reads and all 6 writes succeeded in each of 2 runs.

On a 1 GB database on local disk, plain REINDEX DATABASE took 5.5 to 5.7 seconds and CONCURRENTLY took 6.2 to 8.4, three runs each.

Run REINDEX first, then refresh, on each database that warns:

docker compose exec -T postgres psql -U postgres -d shop -q -c "REINDEX DATABASE shop;"
docker compose exec -T postgres psql -U postgres -d shop -q -c "ALTER DATABASE shop REFRESH COLLATION VERSION;"
docker compose exec -T postgres psql -U postgres -d postgres -q -c "REINDEX DATABASE postgres;"
docker compose exec -T postgres psql -U postgres -d postgres -q -c "ALTER DATABASE postgres REFRESH COLLATION VERSION;"
docker compose exec -T postgres psql -U postgres -d template1 -q -c "REINDEX DATABASE template1;"
docker compose exec -T postgres psql -U postgres -d template1 -q -c "ALTER DATABASE template1 REFRESH COLLATION VERSION;"

The order matters because the refresh only rewrites the recorded version, and the docs say it doesn't check whether anything was rebuilt. On the ICU volume, refreshing first removed the only warning, while amcheck kept failing with the same errors and lookups kept missing the same rows:

NOTICE:  changing version from 153.120 to 153.128
ERROR:  item order invariant violated for index "customers_email_key"
 sampled | name_lookup_missed | email_lookup_missed 
---------+--------------------+---------------------
    2000 |                447 |                 433

template0 needs no refresh, and trying gives ERROR: invalid collation version change. A volume that came from Alpine gives the same error, and REINDEX alone fixed it.

A column with its own ICU collation in an otherwise libc database has its own warning, and the database refresh doesn't touch it. This query from the docs lists what depends on a changed collation, and the fix is REINDEX on those indexes, then ALTER COLLATION ... REFRESH VERSION:

SELECT pg_describe_object(refclassid, refobjid, refobjsubid), pg_describe_object(classid, objid, objsubid) FROM pg_depend d JOIN pg_collation c ON refclassid = 'pg_collation'::regclass AND refobjid = c.oid WHERE c.collversion <> pg_collation_actual_version(c.oid) ORDER BY 1, 2;

What If REINDEX Fails With a Duplicate Key?

Once duplicates are in, the unique index can't rebuild:

ERROR:  could not create unique index "customers_email_key"
DETAIL:  Key (email)=(cēsis.979504@example.com) is duplicated.

REINDEX stops there and the bad indexes stay in place, all three still failing amcheck. With CONCURRENTLY, the failure also leaves an invalid customers_email_key_ccnew behind, so drop it before you retry:

DROP INDEX CONCURRENTLY IF EXISTS customers_email_key_ccnew;

Find the duplicates with index scans off, because the damaged index can't be trusted:

SET enable_indexscan = off; SET enable_bitmapscan = off; SET enable_indexonlyscan = off;
SELECT email, count(*), min(id) AS keep_id, max(id) AS extra_id FROM customers GROUP BY email HAVING count(*) > 1 ORDER BY 1 LIMIT 3;
          email           | count | keep_id | extra_id 
--------------------------+-------+---------+----------
 abols.107297@example.com |     2 |  107297 |  1001709
 abols.135241@example.com |     2 |  135241 |  1000468
 abols.149017@example.com |     2 |  149017 |  1000774

My lab kept the oldest row because its rows were generated, so treat this as a shape and not a command to paste. Decide which row wins, take a dump first, and check any foreign keys pointing at the rows you remove:

SET enable_indexscan = off; SET enable_bitmapscan = off; SET enable_indexonlyscan = off;
DELETE FROM customers c USING (
  SELECT email, min(id) AS keep_id FROM customers GROUP BY email HAVING count(*) > 1
) d
WHERE c.email = d.email AND c.id <> d.keep_id;

After that delete, REINDEX exited 0, lookups missed 0 of 2,000, and a new duplicate was rejected again. Then refresh.

Does a Major Upgrade Clear the Mismatch?

Dump and restore does, and pg_upgrade doesn't. pg_upgrade carries the old recorded version into the new cluster, so 17-bookworm through the trixie-based upgrade image still warned on 18, with shop|2.36|2.41. A dump restored into a fresh 18 volume recorded 2.41, with no warning.

That's one more reason dump and restore is the route to start with, as long as you carry your locale arguments to the new service.

How Do You Prevent It?

On ICU with both apostrophes, putting the old tag back isn't an undo once the new image has taken writes. Going back after reads alone passed amcheck. After inserts on trixie, 17-bookworm printed no warning, amcheck failed, and lookups missed rows.

REINDEX on bookworm cleared amcheck, and default en_US.utf8 passed each time. So run pg_amcheck after a tag change in either direction, and pin the tag you fixed on.

Name the OS in the tag, so the base image changes only when you decide:

    image: postgres:17-trixie

This is what the tags pointed at when I read them on November 7, 2026:

TagOSglibcICUPostgreSQL
16, 16-trixieDebian 132.417616.15
17, 17-trixie, 17.11, 17.11-trixieDebian 132.417617.11
18, 18-trixie, 18.6, latestDebian 132.417618.6
16-bookworm, 17-bookworm, 18-bookwormDebian 122.367216.15, 17.11, 18.6
16-bullseye, 17-bullseyeDebian 112.316716.9, 17.5, behind current minors
17-alpine, 18-alpineAlpine 3.24musl 1.2.67817.11, 18.6

postgres:17.11 has the same image ID as 17-trixie today, but it pins Postgres and not the OS. The maintainer expects plain tags to move again at Debian Forky. A pin has a shelf life too, as the bullseye tags stuck at 17.5 and 16.9 suggest, so plan the OS move and run the checks above when you make it.

For a new database, the builtin provider records nothing that moves with the OS. Set it on the first start of an empty volume:

    environment:
      POSTGRES_INITDB_ARGS: "--locale-provider=builtin --builtin-locale=C.UTF-8 --locale=C.UTF-8"

That gave shop|b|C.UTF-8|1|1 on both images, with no warning. It needs Postgres 17 or newer, and the docs promise stability only within a major version. The trade is a sort that puts Z before a, which matters for any user-facing ORDER BY, and I didn't test moving an existing database to it.

db_builtin sort|-y A B Z _x a b z é
db_libc sort|a A b B é _x -y z Z

A column collation inside a builtin database still depends on glibc, and it warned after the tag change.

Summary

On default en_US.utf8 from bookworm to trixie I found no change, while ICU with mixed apostrophes, an added lv_LV locale, and a move from Alpine returned wrong rows and accepted duplicates.

Run pg_amcheck on each database, then REINDEX, CONCURRENTLY on a live server, and only then refresh the version, because a refresh on its own hides damage.

Pin the OS in your tag. These results come from containers under WSL2 on my data, so test the change on a copy of your own volume first.

My self-hosting course builds a monthly maintenance routine that covers backups and updates.

Thanks for reading.

And stay awesome!


Frequently Asked Questions

Is my data at risk after a Postgres collation version mismatch warning?

It depends on the locale and provider. On default en_US.utf8, moving from bookworm to trixie changed nothing I could find. On ICU with mixed apostrophes, on an added lv_LV locale, and on a move from Alpine, lookups missed rows and unique indexes let duplicates in. Run pg_amcheck to check your own database.

Does ALTER DATABASE REFRESH COLLATION VERSION fix the problem?

No. It only updates the recorded version and removes the warning. Where the order really moved, index checks failed with the same errors afterwards and lookups still missed rows. Run REINDEX first, then refresh.

Why did the warning appear after a Docker image update?

Plain tags such as postgres:17 moved to Debian 13 trixie, which ships a newer glibc and ICU. The same data directory then reports a different collation version from the one recorded when the database was created, with no Postgres version change involved.

Does no warning mean my indexes are fine?

No. A volume moved from postgres:17-alpine to postgres:17-trixie printed no warning at all, because Alpine records no collation version. It was the worst case I tested, with lookups missing rows and more than half of the duplicate inserts accepted.

Does using ICU avoid the collation version mismatch?

No. ICU warned and broke on the same tag change, because ICU moved with the operating system. The builtin provider and the C locale record nothing that moves with the OS, though builtin needs Postgres 17 or newer and sorts Z before a.

How do I stop this happening again?

Name the OS in the tag, such as postgres:17-trixie, so the base image changes only when you decide. A tag like postgres:17.11 pins Postgres and not the OS. For a new database, the builtin provider removes the dependency on the OS.

Browse all Self-Hosting guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.