Database and the store
The store holds the parts of a memory that are not files: people, aliases and confirmations,
owner decisions, model answers someone paid for, run history, settings the UI edits, and
the place names an opt-in geocoder answered. It
defaults to a SQLite file next to config.yaml. Nothing else needs installing for that default,
and it is not a fallback: SQLite is a first-class backend, and so is PostgreSQL when you want one.
Media, previews, thumbnails and per-attempt artifacts stay files either way; the cache (derived
analysis, hashes, scene prints) is always local SQLite and is never migrated or backed
up.
Four ways to point it, in increasing order of "I already run PostgreSQL for something else":
All four are the same IMMICH_MEMORIES_DATABASE_URL setting (unset means mode 1); nothing else in
the app changes. store copy --to <url> moves an existing SQLite store to any of the others.
1. SQLite file on local disk (default)
sqlite:///~/.immich-memories/store.db, expanded per call so a container's HOME still works.
This is the right choice for the common case: one host, one container, one writer. WAL mode,
busy_timeout=30000 and short write transactions make it hold up fine at the scale this app runs
at (a library in the tens of thousands of assets, not millions).
SQLite's rules, and why they exist:
- Local disk only. Opening the file over NFS, SMB or CIFS is refused unless you set
IMMICH_MEMORIES_ALLOW_NETWORK_SQLITE=1, and even then it warns on every start. WAL mode needs shared memory between the writer and the lock file, which a network filesystem does not give two hosts. This is not a permissions problem you can work around: the file corrupts silently, and you find out when a read comes back wrong. - One writer host. A second process on a second host writing the same file, even on local
disk mounted twice, is the same problem. On Kubernetes this is why the CronJobs in
deploy/kubernetes/base/job.yamlcall the running Deployment'sPOST /api/triggerroute instead of mounting the store PVC themselves: see Kubernetes.
2. A separate PostgreSQL service
docker-compose.yml ships a commented postgres service (postgres:16, pinned by digest), a
commented IMMICH_MEMORIES_DATABASE_URL line on the app service, and a commented
immich-memories-postgres-data volume at the bottom. Uncomment all three, then in .env:
POSTGRES_PASSWORD=a-long-random-password
COMPOSE_PROFILES=postgres
and docker compose up -d. The service sits behind the postgres profile, and
COMPOSE_PROFILES turns it on for every later up too, so an update with a plain
docker compose up -d does not leave the app without its database. On Kubernetes,
deploy/kubernetes/overlays/postgres does the equivalent: it is not referenced by
base/kustomization.yaml, so applying base alone keeps SQLite, and applying the overlay points
the Deployment at a database-secret.yaml you fill in yourself; it does not run PostgreSQL for
you. Terraform: set database_url (empty by default, which keeps SQLite) and, only if you share
the instance, database_schema (default immich_memories).
IMMICH_MEMORIES_DATABASE_URL=postgresql+psycopg://immich_memories:change-me@postgres:5432/immich_memories
Plain PostgreSQL 14+. No extensions, no vchord, no pgvector: every vector comparison in this app
is exact numpy over a small candidate set (one film, one story, a 14-day window), so there is no
extension to install and no extension to keep in step with an upstream image.
3. A separate database on your existing PostgreSQL instance
The same URL, pointed at a database on an instance you already run for something else (including Immich's own PostgreSQL container, if you want to reuse it without touching Immich's schema):
IMMICH_MEMORIES_DATABASE_URL=postgresql+psycopg://immich_memories:change-me@database:5432/immich_memories
database is the service name in Immich's own compose file, so the host resolves when this app
runs in that file (next to your Immich stack). From
anywhere else, use the address that reaches your PostgreSQL.
Create the database and a role scoped to it first:
CREATE ROLE immich_memories WITH LOGIN PASSWORD 'change-me';
CREATE DATABASE immich_memories OWNER immich_memories;
Nothing here reads or writes Immich's own database. This mode exists for hosts that already pay for one PostgreSQL instance and want a second logical database on it rather than a second container.
4. A dedicated schema in Immich's own database
Point the URL at Immich's database, and set the schema so the store's tables land somewhere that
is not public:
IMMICH_MEMORIES_DATABASE_URL=postgresql+psycopg://immich_memories:change-me@database:5432/immich
IMMICH_MEMORIES_DATABASE_SCHEMA=immich_memories
IMMICH_MEMORIES_DATABASE_SCHEMA is not in the shipped compose file: add it to the service's
environment: block next to the URL.
Create the role and schema while connected to Immich's database (immich), with no grants on
anything Immich owns:
CREATE ROLE immich_memories WITH LOGIN PASSWORD 'change-me';
CREATE SCHEMA immich_memories AUTHORIZATION immich_memories;
That is the whole grant. The role owns its own schema and nothing else: no GRANT on public, no
grant on any Immich table, no cross-schema foreign key, no search-path change on Immich's own
role. Alembic's version table (alembic_version) lives inside immich_memories, and autogenerate
is scoped to this app's own MetaData, so a migration here never touches Immich's tables and an
Immich migration never touches this schema. No extensions are needed for either side, so there is
nothing to reconcile between the two.
The one thing this mode does not protect against: a whole-database restore of Immich (pg_dump -d immich or a snapshot restore) restores every schema in that database, this one included. If you
restore Immich from a backup taken before a memory run, that run's decisions and banked answers go
back with it. immich-memories store backup dumps only this schema, independent of Immich's own
backup schedule, if you want the two to have separate retention.
Run history and automation
Every run (its phases, delivery state and what it spent on the model), every nightly automation
attempt, the notification cooldown, the special-days catalogue, and the link from a run id to the
attempt directory it was cut from are store rows. That is what automation's cooldown and "already
made this memory" checks read, so wiping cache.db no longer re-films a memory you already have.
The attempt directories themselves, and the run_metadata.json beside each film, stay files.
Three kinds of work must never run twice at once: a nightly automation pass, an assembly, and a film attempt. Each takes a lease first, and the store decides what a lease is:
- SQLite: a lock file beside
cache.db(.auto.lock,.lock) or in the attempt directory (.lease). The operating system drops it when the process dies. One host, as SQLite always is. - PostgreSQL: an advisory lock in the database, held on a connection the holder keeps open. The server drops it the moment that connection closes, a crash included. So several app instances, on as many machines as you like, can share one PostgreSQL store: whichever takes the lease runs, and the others say another run holds it. Two stores in one database (two schemas) never block each other.
Managing the store
Five commands, all under immich-memories store. They act on whatever store the config points at
(IMMICH_MEMORIES_DATABASE_URL, or database.url, or the default SQLite file).
store status
Backend, URL (password shown as ***), schema, Alembic revision and whether it is at head, the
import record, rows per table, and the file size (SQLite, WAL included) or schema size
(PostgreSQL). It reads the database as it is: it never migrates it, and a SQLite file that does not
exist yet is reported, not created. A SQLite file on NFS, SMB or CIFS gets a warning line.
store backup [--to FILE]
A consistent copy while the app keeps running:
- SQLite:
VACUUM INTO, a compact single file. - PostgreSQL:
pg_dump --format=custom --schema=<schema>. Only the store's schema, never the rest of the database. The row counts and the dump read one snapshot, so the manifest describes the file exactly.
Without --to the file lands in ~/.immich-memories/backups/store-<UTC time>.db (or .dump).
Beside it goes <file>.manifest.json: app version, Alembic revision, backend, schema, rows per table
and when it was taken. Keep the two together; a restore refuses a file without its manifest.
Existing files are never overwritten.
PostgreSQL backups need pg_dump on PATH, at least as new as the server. The Docker image ships
it (postgresql-client, currently 17, which dumps any server up to 17). On a uv/pip install, install
your distribution's postgresql-client; without it the command says so and stops.
store restore --from FILE [--force]
Stop the app first: a restore replaces the database under it.
- It refuses a store that holds any row unless you pass
--force. - SQLite: the backup is copied beside the store file and swapped in, with the old
-waland-shmsidecars removed so they cannot be replayed into it. - PostgreSQL: the store's schema is dropped and
pg_restorerebuilds it. A backup restores under a different schema name too (it is renamed afterwards), as long as the original name is free in that database. A schema that holds tables that are not the store's is refused outright, so a mistypedIMMICH_MEMORIES_DATABASE_SCHEMA=publiccannot take Immich with it. - Afterwards the store is migrated to head and every table's row count is checked against the manifest. A backup from an older release restores and then upgrades like any other old store.
A SQLite backup restores into SQLite and a PostgreSQL one into PostgreSQL. To change backend,
restore into the backend the backup came from, then store copy.
Restore in a container
In a container the app is the process that holds the store, so exec into it is the wrong place.
Stop it and run the restore in a one-off container on the same volumes.
Docker Compose:
docker compose stop immich-memories
docker compose run --rm immich-memories immich-memories store restore \
--from /home/immich/.immich-memories/backups/store-<UTC time>.db --force
docker compose up -d
Kubernetes and Terraform: scale the Deployment to 0, run a copy of the one-off generate Job in
deploy/kubernetes/base/job.yaml with its command replaced by
immich-memories store restore --from /home/immich/.immich-memories/backups/<file> --force (add the
database Secret to it when the store is on PostgreSQL), then scale back to 1:
kubectl -n immich-memories scale deploy/immich-memories --replicas=0
kubectl -n immich-memories apply -f restore-job.yaml && kubectl -n immich-memories wait --for=condition=complete job/<name>
kubectl -n immich-memories scale deploy/immich-memories --replicas=1
On a uv or pip install, stop immich-memories ui and run the restore from a shell, outside the time
the daily job fires.
Moving from SQLite to PostgreSQL: store copy --to URL
immich-memories store copy --to postgresql+psycopg://immich_memories:change-me@postgres:5432/immich_memories
The target is migrated to head, then every table is copied in batches, parents before children,
in one transaction. Afterwards each table's row count and a content digest are compared between the
two stores, and any difference fails the command. A target that already holds rows is refused
unless you pass --force, which empties it first. --schema picks the PostgreSQL schema
(default: the configured one).
Then point IMMICH_MEMORIES_DATABASE_URL at the target and restart. The SQLite file stays where it
was; it is your way back. The same command works the other way, PostgreSQL to SQLite
(--to sqlite:////data/store.db).
store import [--from DIR] [--verify]
Brings the files an install from before the store kept (people.yaml, special-days.json,
cache.db's run history and asset scores, the run index, annotations.sqlite, judgments.db, the
structure-banks/ audience and vote banks, the owner edits beside reviewed films) into the store. It runs by itself once, the first time a new version opens a store with no import record
while those files exist (upgrading); this command is
for running it by hand, from another directory, or again.
- The files are opened read-only and never changed or deleted.
- A record the store already holds is never replaced by an older one from a file.
- After each domain (people, then annotations and owner decisions, then run history, then the banks) completes, its files' path, size, mtime and SHA-256 are recorded in the store. A rerun skips a domain whose files have not changed, and an interrupted import finishes where it stopped.
--verifyreads the files again and checks that every legacy record is in the store with the same values: exactly for owner decisions, owner edits, people and special days, to within float rounding for model answers. An audience hold passes when the store's is the same or stricter: the import may tighten a hold, never loosen one. Any difference is listed (table and key, never the values) and the command exits 1.
--from defaults to IMMICH_MEMORIES_IMPORT_FROM, then database.import_from in config.yaml, then
~/.immich-memories.