Import/export — your data out and in, as CSV or JSON
On Zkrinet: add the block under Building blocks in the panel — one click, your own code is untouched and a snapshot is taken first. The installation steps below apply if you run PocketBase yourself, for example after an export.
The freedom principle in a plugin: a superuser can take out a whole collection as CSV (opens in Excel/Numbers/Google Sheets) or JSON — and read it back in, or read in data from a spreadsheet. The import is all or nothing: if one row is wrong, nothing is saved.
Validation and saving are PocketBase's own ($app.save in a transaction) — the same checks as when you save in the admin panel.
Tested against PocketBase 0.40.4.
#Installation
Copy pb_hooks/ to your PocketBase and restart. The plugin has no collections of its own.
#Export
GET /api/zk/export/{samling}?format=csv|json (superuser)
| Parameter | Default | |
|---|---|---|
format | csv | csv or json |
avgransare | komma | semikolon gives ; — what Excel with Swedish settings expects |
dolda | off | 1 = include fields that are marked as hidden. Password hashes and tokenKey are never included, not even then. |
- CSV: UTF-8 with BOM (so that Excel shows å, ä, ö correctly), CRLF line endings, first row = field names. Values with commas, semicolons, quotation marks or line breaks are quoted according to RFC 4180 (
"säger ""hej"""). - JSON: a list of objects, values in their real types (numbers, true/false, lists, JSON fields as JSON).
- Relations become ids. File fields become file names (the files themselves are not included). Multi-value fields (relations, selects, files) become a JSON list in CSV:
["a","b"]. - Protection against formula injection: a text cell that starts with
=,+,-,@, tab or'gets a'prepended in the CSV, so that Excel shows it as text instead of running it as a formula. The import removes it again — a round trip gives the same value.
// From the app (superuser logged in) — e.g. a "Download" button in an admin view
const csv = await fetch(`${pb.baseURL}/api/zk/export/kunder?format=csv&avgransare=semikolon`, {
headers: { Authorization: pb.authStore.token },
}).then((r) => r.blob())
#Import
POST /api/zk/import/{samling}?lage=skapa|uppsert&prova=1 (superuser)
Content-Type: text/csv → the body is CSV
Content-Type: application/json → the body is [ {...}, ... ] or { "lage": "...", "prova": true, "poster": [ ... ] }
const fil = input.files[0] // a .csv from Excel
const svar = await fetch(`${pb.baseURL}/api/zk/import/kunder?lage=uppsert&prova=1`, {
method: "POST",
headers: { Authorization: pb.authStore.token, "Content-Type": "text/csv" },
body: await fil.text(),
}).then((r) => r.json())
// { ok: true, lage: "uppsert", prova: true, rader: 120, skapade: 100, uppdaterade: 20, fel: [] }
// or { ok: false, ..., fel: [ { rad: 7, fel: "epost: Must be a valid email address." } ] }
lage=skapa (default) | Each row becomes a new record. A row with an id that already exists is an error. |
lage=uppsert | Row with an id that exists → the record is updated (only the columns in the file are changed). Otherwise it is created. |
prova=1 | Dry run: everything is validated and test-saved in the transaction — including unique keys — and then always rolled back. Saves nothing. |
- All or nothing: the whole import runs in one transaction. A single error → nothing is saved, and the response lists the errors (up to 100) with row numbers: in CSV, the row in the spreadsheet (the header is row 1, the first data row is row 2); in JSON, the record's position in the list (1 = first).
idmay be given (kept — so relations between collections hold on a round trip) or omitted (PocketBase creates one).- The delimiter is guessed from the header row (
,or;). The BOM is removed. - Values in CSV: numbers with a dot or comma (
3,5); true/false astrue/false,1/0,ja/nej,sant/falskt(empty = false); multi-value as a JSON list or comma-separated (a,b); JSON fields as JSON; dates as PocketBase dates (2026-11-07 12:00:00.000Z). - Invalid values are rejected with a readable error. (PocketBase itself otherwise silently turns
"abc"into 0 in a number field,"kanske"(maybe) into false and an invalid date into empty — we have measured this, and the import does not allow it.) - Unknown columns stop the whole file before anything is done (probably the wrong collection or a misspelled field). Duplicate or empty column names likewise.
- Users (auth collections): the column
passwordsets a password (hashed by PocketBase). If it is missing, the account gets a random password, and the user chooses their own via "forgot password".tokenKeycannot be imported. - Ignored on import: autodate fields (
created/updatedand similar are set by PocketBase) and file fields (file names cannot become files; withuppsertexisting files are left untouched).
#Security
- Both routes require superuser. Guests and regular users get 401/403.
- System collections are rejected (
_superusers,_otps,_mfas,_externalAuths,_authOrigins…), both by name and by id. - Append-only logs cannot be imported into (
zk_granskning,zk_gdpr_samtycken) — that would be forging them. They can be exported. - Views can be exported but not imported into.
- Hidden fields are only included when you ask for it (
dolda=1), password hashes andtokenKeynever. A CSV with hidden fields on your computer is as sensitive as the database — treat it that way.
#Caps
| Import body | 10 MiB. The server reads at most that much and responds 413 (much larger bodies may get the connection closed instead). |
| Rows per import | 10,000 (413 above that) |
| Records per export | 100,000 (413 — export in pages via PocketBase's regular API instead) |
| Reported row errors | 100, then validation is aborted (fler_fel: true) |
The export and the import are built in memory. Larger moves: split the file.
#Limitations — read before you promise a customer anything
- The files are not included — only the file names. Moving files is a separate matter (e.g. copy
pb_data/storageor the S3 bucket). created/updatedare reset on import. Other fields (andid) are identical on a round trip, tested with Swedish characters, emoji, quotation marks, line breaks, commas and semicolons.- The API rules (
createRuleetc.) are not checked — the import runs as superuser. The fields' validation (required, pattern, min/max, unique, valid relations) runs. - Other plugins' hooks run as on a regular save (e.g. the CMS's slug and HTML sanitising). On a dry run and on errors the database changes are rolled back, but a hook that does something outside the database directly (not "after a successful save") cannot be undone.
- Changes via import are not logged by the audit log (it logs API calls; the import saves in server code).
- A text cell that really should start with
'followed by=,+,-,@or'loses its first'on import (the price of the formula protection). Values that just start with'followed by something else are not touched. - An import locks the database for writing while it runs (one transaction).
#Testing
./bank.py import-export
13 tests: round trip JSON and CSV (tricky values, Swedish characters, emoji, relations, multi-value, JSON, geo, dates, hidden fields) gives identical data; Python's own CSV reader reads the export correctly; semicolon and Excel values (decimal comma, ja/nej); all or nothing (both validation errors and a unique key in the database); dry run; create/upsert; unknown columns and broken CSV/JSON; guests and users rejected; system collections (name and id), logs and views rejected; hidden fields and passwords; import of users; file fields; the caps. The protections are mutant-verified: 25 mutants, 24 caught. The one that survives is our view block — PocketBase itself refuses to save in a view, so the import gets 400 anyway.