PocketBase nimmt dir mit seiner REST-API und dem Admin-Dashboard viel Arbeit ab. Früher oder später kommst du aber an einen Punkt, an dem Filter und Standard-Endpunkte nicht mehr reichen: Du willst Daten gruppieren, Summen bilden, mehrere Collections verknüpfen oder eine Statistik für dein Dashboard berechnen. Genau dann lohnt sich ein Blick unter die Haube. PocketBase speichert alles in einer SQLite-Datenbank, und du kannst mit echtem SQL direkt darauf zugreifen.
In diesem Beitrag zeige ich dir, welche Wege es gibt, SQL-Abfragen in PocketBase zu nutzen, wie du sie sicher schreibst und worauf du bei Performance und Datenstruktur achten solltest.
Warum SQL in PocketBase?
PocketBase ist ein Backend in einer einzigen Datei. Unter der Oberfläche steckt SQLite, und jede Collection ist eine ganz normale Tabelle. Die REST-API mit ihrer Filtersyntax wie status = "active" && created > "2026-01-01" deckt die meisten Alltagsfälle ab. Für folgende Aufgaben brauchst du aber SQL:
- Aggregationen:
COUNT,SUM,AVGundGROUP BYfür Auswertungen und Reports. - Komplexe Joins: Daten aus mehreren Collections in einer einzigen Abfrage zusammenführen.
- Massenänderungen: Viele Datensätze auf einmal aktualisieren, ohne jeden einzeln über die API anzufassen.
- Eigene Endpunkte: Maßgeschneiderte API-Routen, die genau die Daten liefern, die dein Frontend braucht.
Die vier Wege zu SQL in PocketBase
Es gibt nicht nur einen Weg, SQL in PocketBase einzusetzen. Je nach Anwendungsfall wählst du eine der folgenden Varianten:
1. View Collections: SQL direkt im Dashboard
Der einfachste Einstieg sind View Collections. Du legst im Admin-Dashboard eine neue Collection vom Typ „View“ an und hinterlegst eine SELECT-Abfrage. PocketBase behandelt das Ergebnis wie eine schreibgeschützte Collection. Du kannst sie über die normale REST-API abfragen, filtern, sortieren und mit API-Regeln absichern.
Ein Beispiel: Du hast eine Collection orders mit einer Relation customer und einem Feld total. Diese View liefert dir den Umsatz pro Kunde:
|
1 2 3 4 5 6 7 8 |
SELECT c.id, c.name, COUNT(o.id) AS order_count, SUM(o.total) AS revenue FROM customers c LEFT JOIN orders o ON o.customer = c.id GROUP BY c.id |
Wichtig dabei:
- Jede View braucht eine Spalte
id. Die muss pro Zeile eindeutig sein. Bei Aggregationen ohne natürliche ID kannst du z. B.(ROW_NUMBER() OVER()) AS idverwenden. - Views sind nur lesend. Create, Update und Delete gibt es hier nicht.
- Die Abfrage wird bei jedem Request ausgeführt. Bei großen Datenmengen spielen Indizes deshalb eine große Rolle.
Danach rufst du die View wie jede andere Collection ab:
|
1 2 3 |
const stats = await pb.collection('customer_revenue').getList(1, 20, { sort: '-revenue', }); |
2. SQL in JavaScript-Hooks
Wenn du PocketBase mit JavaScript erweiterst (Dateien im Ordner pb_hooks), bekommst du über $app.db() Zugriff auf den Query Builder. Damit kannst du beliebige SQL-Abfragen ausführen, lesend und schreibend.
So baust du einen eigenen Endpunkt, der Statistiken liefert:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// pb_hooks/stats.pb.js routerAdd("GET", "/api/stats/orders", (e) => { const result = arrayOf(new DynamicModel({ month: "", order_count: 0, revenue: 0, })); $app.db() .newQuery(` SELECT strftime('%Y-%m', created) AS month, COUNT(*) AS order_count, SUM(total) AS revenue FROM orders WHERE created >= {:since} GROUP BY month ORDER BY month `) .bind({ since: "2026-01-01" }) .all(result); return e.json(200, result); }, $apis.requireAuth()); |
Ein paar Details, die dir Zeit sparen:
DynamicModelbeschreibt die Form des Ergebnisses. Die Standardwerte bestimmen den Typ:""für Text,0für Zahlen,falsefür Booleans.arrayOf()brauchst du für mehrere Zeilen mit.all(). Für genau eine Zeile nimmst du ein einzelnes Model und.one().{:name}ist ein Platzhalter, den du über.bind()befüllst. Dazu gleich mehr.
Für schreibende Operationen verwendest du .execute():
|
1 2 3 4 |
$app.db() .newQuery("UPDATE orders SET status = 'archived' WHERE created < {:date}") .bind({ date: "2025-01-01" }) .execute(); |
3. SQL in Go
Nutzt du PocketBase als Go-Framework, funktioniert das ganz ähnlich über app.DB(). Du hast die Wahl zwischen rohem SQL und dem fluenten Query Builder der Bibliothek dbx:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
type MonthlyStat struct { Month string `db:"month" json:"month"` OrderCount int `db:"order_count" json:"order_count"` Revenue float64 `db:"revenue" json:"revenue"` } var stats []MonthlyStat err := app.DB(). NewQuery(` SELECT strftime('%Y-%m', created) AS month, COUNT(*) AS order_count, SUM(total) AS revenue FROM orders WHERE created >= {:since} GROUP BY month `). Bind(dbx.Params{"since": "2026-01-01"}). All(&stats) |
Oder mit dem Builder, ganz ohne SQL-String:
|
1 2 3 4 5 6 7 |
err := app.DB(). Select("id", "title", "created"). From("articles"). Where(dbx.HashExp{"status": "published"}). OrderBy("created DESC"). Limit(10). All(&articles) |
4. Direkt mit sqlite3 auf die Datenbank
Die Datei pb_data/data.db ist eine ganz normale SQLite-Datenbank. Du kannst sie mit dem sqlite3-Kommandozeilenwerkzeug oder einem Tool wie DB Browser for SQLite öffnen. Das ist ideal, um schnell etwas nachzuschauen oder eine Abfrage zu testen, bevor du sie in eine View oder einen Hook übernimmst.
|
1 |
sqlite3 pb_data/data.db "SELECT status, COUNT(*) FROM orders GROUP BY status;" |
Schreibende Zugriffe solltest du auf diesem Weg vermeiden, solange PocketBase läuft. Du umgehst damit Hooks, Validierung und API-Regeln, und PocketBase bekommt von deinen Änderungen nichts mit. Mach vorher in jedem Fall ein Backup.
Sicherheit: Immer Parameter binden
Der wichtigste Grundsatz beim Arbeiten mit SQL-Abfragen: Baue niemals Benutzereingaben per String-Verkettung in deine Query ein. Das öffnet Tür und Tor für SQL-Injection.
|
1 2 3 4 5 6 7 |
// Gefährlich: niemals so! $app.db().newQuery("SELECT * FROM users WHERE email = '" + email + "'"); // Richtig: Parameter binden $app.db() .newQuery("SELECT * FROM users WHERE email = {:email}") .bind({ email: email }); |
Beim Binden kümmert sich die Datenbank um das korrekte Escaping. Denk außerdem daran: Eigene Routen und Hooks umgehen die API-Regeln deiner Collections. Prüfe selbst, ob der aktuelle Nutzer die Daten sehen darf, etwa mit $apis.requireAuth() oder einer Bedingung auf e.auth.id in deiner Abfrage.
Besonderheiten der PocketBase-Datenstruktur
Wenn du SQL schreibst, arbeitest du direkt mit der internen Struktur. Ein paar Dinge solltest du kennen:
- IDs sind zufällige 15-stellige Strings, keine fortlaufenden Zahlen.
- Datumsfelder wie
createdundupdatedwerden als Text im Format2026-11-01 10:30:00.000Zgespeichert. Du kannst sie trotzdem mit SQLite-Funktionen wiestrftime()unddate()auswerten und als String vergleichen. - Einfache Relationen speichern die ID des verknüpften Datensatzes als Text. Ein Join funktioniert ganz normal mit
ON o.customer = c.id. - Mehrfach-Relationen und Select-Felder mit mehreren Werten werden als JSON-Array gespeichert. Hier hilft dir
json_each().
Ein Beispiel für eine Mehrfach-Relation: Ein Artikel hat mehrere Tags im Feld tags. So zählst du, wie oft jeder Tag verwendet wird:
|
1 2 3 4 5 6 7 8 |
SELECT t.id, t.name, COUNT(*) AS usage FROM articles a, json_each(a.tags) AS j JOIN tags t ON t.id = j.value GROUP BY t.id ORDER BY usage DESC |
Auf dieselbe Weise liest du Werte aus JSON-Feldern mit json_extract(meta, '$.author') aus.
Welcher Weg passt zu dir?
Performance: Indizes und EXPLAIN
SQLite ist erstaunlich schnell, aber nur, wenn deine Abfragen passende Indizes finden. Filterst oder joinst du regelmäßig über ein Feld wie customer oder status, lege dafür einen Index an. Das geht in den Einstellungen der Collection im Dashboard unter „Indexes“:
|
1 |
CREATE INDEX idx_orders_customer ON orders (customer) |
Ob SQLite deinen Index nutzt, verrät dir EXPLAIN QUERY PLAN:
|
1 2 |
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer = 'abc123def456ghi'; |
Steht im Ergebnis SEARCH ... USING INDEX, ist alles gut. Siehst du dagegen SCAN orders, durchsucht SQLite die komplette Tabelle. Bei ein paar hundert Einträgen merkst du das kaum, bei hunderttausenden schon.
Weitere Tipps:
- Wähle nur die Spalten aus, die du wirklich brauchst, statt
SELECT *. - Nutze
LIMITbei großen Ergebnismengen. - Fasse mehrere schreibende Abfragen in einer Transaktion zusammen, z. B. mit
$app.runInTransaction((txApp) => { ... }). Innerhalb der Transaktion verwendest du danntxApp.db().
Fazit
PocketBase macht dir den Einstieg leicht, sperrt dich aber nicht ein. Weil unter der Haube SQLite arbeitet, kannst du jederzeit zu echtem SQL greifen. Für Auswertungen, die über die API verfügbar sein sollen, sind View Collections die erste Wahl. Brauchst du volle Kontrolle oder willst du Daten ändern, gehst du über $app.db() in JavaScript-Hooks oder app.DB() in Go. Und für schnelle Analysen öffnest du die Datenbank einfach mit sqlite3. Wenn du dabei immer Parameter bindest und an deine Indizes denkst, holst du das Maximum aus deinem Backend heraus.
Alle Details zum Query Builder findest du in der offiziellen PocketBase-Dokumentation zur Datenbank. Die verfügbaren SQL-Funktionen, etwa für JSON und Datumswerte, sind in der SQLite-Dokumentation beschrieben.
FAQ: SQL-Abfragen in PocketBase
PocketBase nutzt SQLite. Alle Daten liegen in der Datei Nein, und das ist auch gut so. Die REST-API akzeptiert nur die eigene Filtersyntax. Wenn du SQL-Ergebnisse über die API bereitstellen willst, nutzt du eine View Collection oder einen eigenen Endpunkt in einem Hook. Nein. View Collections sind schreibgeschützt. Für Änderungen brauchst du JavaScript-Hooks, Go-Code oder die normalen Collection-Endpunkte. Für View Collections ja, dort legst du List- und View-Regeln wie gewohnt fest. Abfragen über Verwende immer Platzhalter wie Mehrfach-Relationen werden als JSON-Array gespeichert. Mit
Welche Datenbank verwendet PocketBase?
pb_data/data.db. Jede Collection entspricht einer Tabelle in dieser Datenbank.Kann ich über die REST-API direkt SQL senden?
Kann ich in einer View Collection Daten ändern?
Gelten die API-Regeln auch für meine eigenen SQL-Abfragen?
$app.db() oder app.DB() umgehen die Regeln dagegen. Hier bist du selbst für die Zugriffsprüfung verantwortlich.Wie schütze ich mich vor SQL-Injection?
{:name} und übergib die Werte über .bind() bzw. Bind(). Setze Benutzereingaben niemals per String-Verkettung in die Abfrage ein.Wie frage ich Mehrfach-Relationen mit SQL ab?
json_each() zerlegst du das Array in einzelne Zeilen und kannst sie anschließend ganz normal joinen.