PocketBase v0.22 Broke My Migrations: It's `fields`, Not `schema`
An upgrade flipped the collection API and silently broke my generated migration code. Here is exactly what changed and how to fix it.

I run PocketBase as the backend for most of my projects. It is a single Go binary, it ships SQLite, and it is the boring backend I keep recommending over the heavier hosted options. Then I pulled the latest image to stand up a new project, ran my migration script, and watched it fall apart in four different places. The first error was a 404 on auth. The next was the strange one: a collection got created with a 200 status and then turned out to be completely empty. No fields. No error. Just a hollow collection sitting in the dashboard.
The cause is a set of breaking changes that landed in PocketBase v0.22 and are present in ghcr.io/coollabsio/pocketbase:latest. The headline change is that the collection API renamed schema to fields, and it flattened every field's options up to the top level. If you have migration code written against v0.20 or earlier, or if you pasted PocketBase code an LLM generated for you, it will break, and one of the failure modes is silent.
What changed in v0.22
PocketBase rewrote the collection and admin model. Two things drive every error you will hit. First, the "users" and "admins" concepts were unified into a single _superusers system collection, which moves the admin auth endpoint. Second, the collection definition format changed: the schema key became fields, and the per-field options object was dissolved so its keys sit directly on the field.
Here is the shape of a single select field before and after.
Before (v0.20 and earlier):
{
"type": "select",
"name": "category",
"options": {
"maxSelect": 1,
"values": ["a", "b", "c"]
}
}
After (v0.22+):
{
"type": "select",
"name": "category",
"maxSelect": 1,
"values": ["a", "b", "c"]
}
The options wrapper is gone. maxSelect and values moved up a level. The same flattening applies to other field types: relation moves its collectionId up, file moves maxSize and mimeTypes up, and text moves max, min, and pattern up.
The code that broke
My migration script created collections by POSTing a spec to the PocketBase API. Here is the kind of payload it sent, written for the old format.
Before, the payload that used to work:
spec = {
"name": "articles",
"type": "base",
"schema": [
{
"type": "text",
"name": "title",
"options": {"max": 200},
},
{
"type": "select",
"name": "category",
"options": {"maxSelect": 1, "values": ["news", "guide", "review"]},
},
],
}
On v0.22, this fails in two stacked ways. The schema key is ignored, so even if the rest validated, the collection would come up with zero fields. And the nested options on the select field trips validation, returning a 400 with the body "data": {"fields": {"1": {"values": {"code": "validation_required"}}}}. That message reads like the values array is missing. It is not missing. It is just one level too deep, and the validator only looks at the top level now. The "1" in that error is the field index, which is the one clue that points you at which field is wrong.
After, the payload that works on v0.22+:
spec = {
"name": "articles",
"type": "base",
"fields": [
{
"type": "text",
"name": "title",
"max": 200,
},
{
"type": "select",
"name": "category",
"required": True,
"maxSelect": 1,
"values": ["news", "guide", "review"],
},
],
}
The auth call broke too, and it broke first, before any collection logic even ran. The old admin login endpoint returns a 404:
POST /api/admins/auth-with-password -> 404
The new one lives under the unified superusers collection:
POST /api/collections/_superusers/auth-with-password -> 200
The request body is unchanged. It still takes {"identity": email, "password": pw}. Only the path moved.
There is a fourth trap that bites a step later, when you try to add indexes. In older PocketBase you could index the built-in created and updated timestamps directly. On v0.22 those are no longer implicit indexable columns, so CREATE INDEX foo ON bar (created) returns a 400 with no such column: created. You now have to declare them as explicit autodate fields before you can index them.
How to migrate cleanly
Fix all four at once. They hit in sequence during a single migration run, so patching one just surfaces the next. This is the working collection POST template I now use on v0.22+.
spec = {
"name": "my_collection",
"type": "base",
"fields": [
{"name": "title", "type": "text", "required": True, "max": 200},
{"name": "category", "type": "select", "required": True,
"maxSelect": 1, "values": ["a", "b", "c"]},
{"name": "metadata", "type": "json"},
{"name": "created", "type": "autodate", "onCreate": True, "onUpdate": False},
],
"indexes": [
"CREATE INDEX `idx_cat` ON `my_collection` (`category`)",
"CREATE INDEX `idx_created` ON `my_collection` (`created`)",
],
}
Walking the four fixes:
| What broke | Old | New |
|---|---|---|
| Admin auth | POST /api/admins/auth-with-password |
POST /api/collections/_superusers/auth-with-password |
| Schema key | {"schema": [...]} |
{"fields": [...]} |
| Field options | {"type": "select", "options": {"maxSelect": 1, "values": [...]}} |
{"type": "select", "maxSelect": 1, "values": [...]} |
| Timestamp index | CREATE INDEX ... (created) on the implicit column |
declare {"name": "created", "type": "autodate", "onCreate": true, "onUpdate": false} first |
For the updated timestamp, the same autodate declaration applies, but set both flags: "onCreate": true, "onUpdate": true. Then the index on updated works.
If you write collections inside PocketBase hooks rather than over the API, the same fields rename and the same flattening apply to your users collection definitions there too. It is one consistent rule across both surfaces: the field array is fields, and option keys are top-level.
What broke for me
The moment it bit was a build I was standing up on 9 May 2026, deployed through Coolify on ghcr.io/coollabsio/pocketbase:latest. I had migration code that worked fine against my older PocketBase instances, so I assumed it would just run. It did not. The auth call 404'd immediately because of the _superusers move. I fixed the path, re-ran, and the script reported the collection created with a 200, which looked like success. It was not. The collection was empty because my payload still used schema, and the new server silently ignored that key. That silent partial success is the part that costs you time, because nothing tells you to look. You see a 200, you assume it worked, and you only notice much later when a query against that collection returns nothing and you go digging.
Once I started reading the actual error bodies instead of trusting the status codes, the select-field 400 gave it away. The "fields": {"1": ...} shape in the response was the tell that the server already expected fields, and the validation_required on values was the flattening problem, not a missing array. All four fixes went in together, and the migration ran clean.
A note on AI-generated PocketBase code
This one matters if you let an LLM write your PocketBase code, which a lot of people do now. Most models were trained on a corpus that is heavy with pre-v0.22 PocketBase. So when you ask for a collection migration or a hook, there is a strong chance the model emits the old schema shape with nested options, because that is what the bulk of the training examples look like. It will look correct. It will be confidently formatted. And on a current PocketBase server it will give you the same silent empty collection I got.
The fix is mechanical, so it is easy to catch on review. Before you run any generated PocketBase migration, scan it for two strings. If you see a top-level "schema": key on a collection spec, rename it to "fields":. If you see an "options": { nested inside a field, dissolve it and lift its keys up. Those two greps catch the breaking changes that an out-of-date model will reproduce. I treat any AI-written backend code as pre-0.22 until I have checked those two patterns. For the wider case of building a SaaS backend with AI assistance on this stack, I wrote up the full setup in PocketBase as a SaaS backend with Claude.
When this matters
You need these four fixes if you are upgrading an existing PocketBase instance from v0.20 or earlier, if you are pulling :latest for a fresh deploy while reusing old migration code, or if any of your collection and hook code was generated by a model trained before this change. If you are starting completely fresh and copying field definitions from the current PocketBase docs or the dashboard's API preview, you are already on the new format and none of this will bite you.
The good news is that this is a one-time port. PocketBase is still the lean backend I reach for first, and the migration is four targeted edits, not a rewrite. The trap is purely that one of the four fails silently, so the rule that saved me is simple: read the response body, not the status code, and never trust a 200 on a collection create until you confirm the fields actually landed.
Related
More AI Coding

Building a Custom MCP Server in Python: Claude Reaches My Stack
Claude Code is sharp until it hits the edge of your machine and your private tools. I wrote three small MCP servers in Python to close that gap. Here is the real pattern, the real gotcha that bit me, and what it costs.

Claude Code Subagents in Practice: Fork Flag, Cache Leak, Worktree Trap
Fanning out subagents in Claude Code looks free until you hit the cap or your forks clobber each other's commits. These are the real fixes I learned running fanouts: the fork env flag that shares the parent's cache, the WebFetch cache leak, and the worktree pattern for parallel writers.

I Gave My AI Agents a Memory With SQLite FTS5 (No Vector DB)
Most agent-memory setups reach for Pinecone or pgvector by reflex. I put 2000+ markdown files behind SQLite FTS5 with BM25 ranking, and my agents now answer their own 'who is X' questions in under a second for zero tokens. Here is the schema, the query, and the one place lexical search loses.