We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide13 min read

PocketBase, or a backend that fits in one file

PocketBase packs a database, auth, files, and an API into one executable. Access rules, extending it in Go, limitations, and its pre 1.0 status.

PocketBase, or a backend that fits in one file

The idea sounds improbable until you see it running. You download one executable, run it, and have a database, authentication with external login providers, file uploads, realtime notifications, an admin panel, and a ready API.

No installer, no dependencies, no service configuration. One file and a data directory beside it.

Code
Bash
./pocketbase serve

The panel works immediately, so does the API, and the first data collection takes a minute to create. For a prototype, an internal application, or a side project that is a saving measured in days rather than hours.

The caveat that belongs at the start

The project has not reached version one and says so itself. The documentation states plainly that full backward compatibility is not guaranteed before 1.0, and the author advises against using it in critical applications unless you are comfortable reading the changelog and performing manual steps during updates.

That is an honest framing and deserves respect rather than being discovered at the third update.

The practical conclusion is not "do not use it" but "use it knowingly". An internal application, a panel for ten people, a prototype shown to an investor, a tool for one team: there the risk is negligible and the gain real. A system handling external customer payments is a different conversation.

Pin the version and read the changelog before every update. Those two habits reduce the entire risk to a minor inconvenience.

Collections and access rules

Data structure is created in the panel or through migrations, and every collection carries its own rules stating who may do what.

A rule is an expression comparing document fields with the logged in user's data. It looks unremarkable and is the most important security mechanism here.

Code
TEXT
listRule:   @request.auth.id != ""
viewRule:   @request.auth.id != ""
createRule: @request.auth.id != ""
updateRule: author = @request.auth.id
deleteRule: author = @request.auth.id

Those five say a logged in user sees everything but can change and delete only their own entries. A rule has three states, and those states are what people confuse most. An unset rule, marked with a padlock in the panel, admits superusers alone, and that is the default state of every new collection. A rule unlocked and left empty admits anybody, a client with no token included. A rule holding an expression admits whoever satisfies it.

The direction of the most common mistake runs opposite to intuition here. An untouched collection does not leak, since it is closed by default. The one that leaks is the collection somebody unlocked to get a prototype moving and left with an empty field instead of an expression. A collection of personal data exposed that way and forgotten stays open to the world.

Check that directly, calling the API without an authentication token, before anything reaches the internet. One command answers a question the panel answers ambiguously.

Code
Bash
curl -s "http://127.0.0.1:8090/api/collections/tasks/records" | head -c 300

If the response carries records rather than an empty list or a 403, the collection is open to anyone. A locked rule returns 403 and an unsatisfied list rule returns 200 with an empty array, so the distinction shows only in the response body. This is a test worth adding to the pre deployment checklist and repeating after every rule change, because the panel shows the rules rather than their effect.

Starting the server and creating the first administrative account takes two commands, with no installer and no dependencies.

Code
Bash
./pocketbase superuser create admin@example.com password-min-8-chars
./pocketbase serve --http=127.0.0.1:8090

The client and realtime work

The client library hides API details and offers one coherent interface.

Code
TypeScript
import PocketBase from 'pocketbase'

const pb = new PocketBase('http://127.0.0.1:8090')

await pb.collection('users').authWithPassword('anna@example.com', 'password')

const tasks = await pb.collection('tasks').getList(1, 20, {
  filter: 'completed = false',
  sort: '-created',
  expand: 'assignee'
})

Subscribing to changes is one line and needs no server side configuration at all.

Code
TypeScript
pb.collection('tasks').subscribe('*', (e) => {
  if (e.action === 'create') addToList(e.record)
  if (e.action === 'update') refresh(e.record)
})

Knowing how that works underneath matters, since it has consequences. Notifications travel over a server sent event stream, meaning an ordinary long lived HTTP connection rather than a bidirectional socket. That is simpler and passes through network intermediaries better, while every open connection consumes resources, so at thousands of concurrent clients it deserves measuring.

Expanding relations through the expand parameter saves extra queries and is one of those things whose absence is noticed only on a slow loading list. Without it a list of twenty tasks with assignees produces twenty one requests instead of one, and the gap grows in proportion to list length.

Extending it in Go

Here lies the difference between a toy and a tool you can use seriously. PocketBase is a program and equally a library, so you write your own logic in code and build your own executable.

Code
Go
package main

import (
    "log"
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
)

func main() {
    app := pocketbase.New()

    app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error {
        sendConfirmation(e.Record.GetString("email"))
        return e.Next()
    })

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Events cover every record operation, authentication, and request handling, so validation, notifications, and integrations have somewhere to live. Custom API routes can be added alongside the generated ones.

That solves a problem typical of ready made backends: what to do when you need something the author did not anticipate. Here the answer is writing it in Go rather than hunting for a workaround.

The price is that custom logic requires knowing Go and a separate build step. If the team writes only in browser languages, that part stays unused, and some of the advantage disappears with it.

Authentication and user accounts

The login layer is ready made here, and it is one of the things that most shortens the path to a first working version.

A users collection exists from the start and supports password login, email confirmation, password recovery, and login through external identity providers. Enabling login through a popular service means entering two values in the panel, with no code written.

Code
TypeScript
await pb.collection('users').authWithOAuth2({ provider: 'google' })

The token lands in client memory and is attached automatically to later requests, with the library handling refresh. It pays to know where that token sits, though, since the default storage in browser memory means a script injected into the page can read it.

More than one account collection can be created, and that is sometimes useful. A separate collection for customers and another for staff lets you keep different fields and different rules, rather than cramming everyone into one table with a role field.

A user's role is an ordinary field whose value must be protected by a rule. A role field editable by the record's owner lets anyone promote themselves to administrator with one request, and that mistake is made more often than you might expect.

Files, migrations, and teamwork

Three things that do not matter on a prototype and decide working comfort on a project living longer than a month.

File upload is built in: a field of the appropriate type accepts attachments, and the server generates image thumbnails at the sizes you specify. Files land by default on disk beside the database, which helps with backups and hurts when space runs out. They can be directed to object storage, and on larger collections doing that from the start pays off.

Code
TypeScript
const form = new FormData()
form.append('title', 'Quarterly report')
form.append('attachment', file)

const record = await pb.collection('documents').create(form)
const thumbnail = pb.files.getURL(record, record.attachment, { thumb: '200x200' })

Thumbnails are generated on demand at the first call for a given size, so a list with previews is slow only once. Stick to a few fixed sizes rather than deriving them from window width, since every new size is a separate file on disk.

Schema migrations are recorded automatically when you change the structure in the panel. A file appears, goes into the repository, and runs at startup in another environment. That way a change made locally reaches production alongside the code rather than through clicking in the production panel.

That mechanism works well under one condition: nobody changes the structure directly in production. A change made there by hand has no counterpart in the repository and at the next deployment can be overwritten or cause a conflict.

Team work over one database file is impossible, so every developer runs their own instance locally. That part works well, since startup is instant and test data can be kept as a set loaded after launch.

Deployment and backups

Running it is as simple as the description promises: upload the file to a server, set up a system service and a proxy handling the certificate. One machine costing a few dollars a month will serve an application with thousands of users, since the database is embedded and there is no network traffic between processes.

The consequence of that simplicity is no horizontal scaling. You cannot run three instances behind a load balancer, since each would hold its own database file. Scaling happens vertically, through a stronger machine, and for most applications that suffices longer than intuition suggests.

Backups are unusually simple in this arrangement, since the whole state is a directory holding the database file and uploaded files. The system has a built in backup mechanism with upload to external storage, and enabling it belongs on day one rather than after the first incident.

Test restoring a backup before you need it. A backup nobody has ever restored is a backup in name only, and that sentence has already cost many teams a great deal.

PocketBase against the alternatives

OptionStrengthWeaknessPick it when
PocketBaseOne file, zero configuration, cheap hostingPre 1.0, no horizontal scalingA prototype, internal tool, side project
SupabasePostgres, maturity, managed serviceMore moving partsA production application for customers
AppwriteBroad feature set, self hostingHeavier deployment, several containersA team wanting everything in house
ConvexLogic and data in one modelUsage based billing, smaller communityA heavily interactive application

The decision reduces to one question: is simplicity a value here or a risk. For a team tool, a prototype, and an application of predictable scale, simplicity beats everything else. For a system meant to grow and serve external customers, a more mature option buys peace of mind worth its price.

Note too that migrating away is not dramatic. The data sits in an ordinary file based database you can export, and the collection structure maps onto tables without translating concepts.

Performance and limits

An embedded database has different characteristics from a networked one, and knowing them before the application grows pays off.

Reads are very fast here, since there is no network latency and no serialisation between processes. A query completes in a fraction of a millisecond, which in a typical application means the bottleneck becomes something else, usually client side rendering.

Writes have a different nature. A file based database serves one writer at a time, so concurrent writes queue up. Under ordinary traffic that is imperceptible, while a bulk import of several hundred thousand records performed row by row can run for hours. Batched writes in one transaction cut that to minutes.

Watch the indexes too. A collection with ten thousand records performs well whatever you do, and at a million a missing index on a filtered field turns a list into a loading screen. The panel lets you add an index without writing queries.

The last thing is the database file's size. It grows with the data and with change history, and with files stored alongside it can grow faster than a disk space plan assumes. Monitoring free space on that one machine matters more here than with distributed options, since running out stops everything at once.

Common mistakes

The first is unlocking a rule and leaving it empty. An empty rule means access for anybody, so data exposed during a prototype stays open, while an untouched collection would have stayed closed.

The second is using it in a critical system despite the author's warning. A pre 1.0 project does not guarantee backward compatibility and says so plainly.

The third is updating without reading the changelog. This is one of the few places where that habit genuinely saves an evening.

The fourth is planning for horizontal scaling. An architecture with an embedded database does not anticipate it, so a plan assuming several instances must be dropped at the design stage.

The fifth is having no backups despite the built in mechanism. Enabling it takes a minute, and the whole application state sits in one directory.

The sixth is moving business logic into the browser because it is faster that way. Access rules guard data but do not replace validation, which belongs in server side events.

FAQ

Is PocketBase suitable for production?

It depends what production means in your case. For internal tools, team applications, and projects of predictable scale, yes, with a pinned version and the changelog read. The author advises against it in critical applications until the project reaches version one.

Will it handle heavy traffic?

One machine handles surprisingly much, since the database is embedded and there is no network latency between processes. The limit is the absence of horizontal scaling: you grow with a stronger machine rather than more instances.

How does it differ from Supabase?

In scale and maturity. Supabase offers Postgres, a managed service, and a broader feature set, at the cost of more complexity. PocketBase offers one file and zero configuration, at the cost of pre 1.0 status and no horizontal scaling.

Do I need to know Go?

Not for basic use, since the panel and API work without writing server code. Knowing Go opens up extending it with custom logic and routes, which is the biggest advantage here over comparable options.

What does a backup look like?

The whole state is a directory holding the database file and uploaded files, so a backup means copying that directory. The system has a built in backup mechanism with upload to external storage, and enabling it right away, together with testing a restore, is worth doing.

Documentation sits on the project site, and the code in the GitHub repository.