💻AI Codingintermediate

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.

By··7 min read
PocketBase collection POST body showing the schema key renamed to fields with flattened select options

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