Zum Hauptinhalt springen

Datenbankzugriff mit Go und SQLite

Diese Seite zeigt, wie aus einem Go-Programm auf eine SQLite-Datenbank zugegriffen wird: verbinden, Daten lesen, anlegen, ändern und löschen. SQL-Kenntnisse (SELECT, INSERT, UPDATE, DELETE) werden vorausgesetzt, hier geht es ausschließlich um die Go-Seite. Es wird nur das Standardpaket database/sql verwendet.

Als Beispiel dient die Datenbank Geo.db mit den Tabellen land, stadt und wirtschaft, die bereits für die SQL-Grundlagen verwendet wurde.

Treiber: modernc.org/sqlite

Wir benutzen den Treiber modernc.org/sqlite. Er ist vollständig in Go geschrieben und braucht keinen C-Compiler (gcc), was unter Windows extra Aufwand bedeuten würde.

Projekt einrichten

Der Ablauf ist derselbe wie bei jedem Go-Projekt. Neu ist nur, dass wir ein externes Paket (den Treiber) installieren.

Projektordner und Modul anlegen

Zuerst wird ein Terminal (Eingabeaufforderung / cmd) geöffnet und ein leerer Ordner angelegt:

cd H:\Info_LK
mkdir geo
cd geo
go mod init geo

go mod init geo erzeugt die Datei go.mod – die „Projektkarte“. Hier hält Go fest, welche Pakete das Projekt verwendet.

Treiber installieren

go get modernc.org/sqlite

Dieser Befehl lädt den Treiber aus dem Internet und trägt ihn ein:

  • in go.mod unter require (welches Paket in welcher Version),
  • in go.sum (Prüfsummen, damit später dieselbe Version geladen wird).

Beide Dateien gehören mit ins Projekt. Die heruntergeladenen Pakete selbst landen im Go-Cache, nicht im Projektordner.

Pakete erst nach dem Import herunterladen

Der import-Block (siehe unten) kann auch zuerst geschrieben werden und dann

go mod tidy

ausführen. go mod tidy schaut, welche Pakete der Code wirklich importiert, lädt fehlende nach und entfernt nicht benötigte Einträge. Das ist der bequemste Weg, go.mod/go.sum sauber zu halten.

Arbeitskopie der Datenbank

Die SQLite-Datei Geo.db wird in den Projektordner kopiert. Dort kann sie beliebig verändert werden. Liegt die Datenbank woanders, erscheint später der Fehler unable to open database file.

Notwendige Imports

Go allein kann nicht mit einer SQLite-Datenbank kommunizieren. Dafür binden wir zwei Pakete ein. Außerdem verwenden wir auch das log Paket für Fehlermeldungen:

import (
"database/sql" // Standard-Schnittstelle für SQL-Datenbanken
"fmt" // Ausgabe auf der Konsole
"log" // Fehlermeldungen + Programmabbruch

_ "modernc.org/sqlite" // Treiber: nur registrieren, nicht direkt benutzen
)
  • database/sql – die einheitliche Schnittstelle, über die wir Abfragen und Befehle schicken.
  • fmt – gibt normale Ergebnisse auf der Konsole aus.
  • log – ist für erwartbare Fehler da: log.Fatal(err) gibt die Meldung aus und beendet das Programm sofort.
  • _ "modernc.org/sqlite" – der eigentliche Treiber, der database/sql mit SQLite verbindet.

Warum der Unterstrich _?

Aus dem Treiber rufen wir nichts direkt auf, trotzdem muss er geladen werden. Beim Import führt Go automatisch dessen init-Funktion aus und die meldet den Treiber unter dem Namen "sqlite" an. Der Unterstrich _ sagt „nur wegen dieser Nebenwirkung laden“. Ohne ihn bräche Go mit dem Fehler „importiert, aber nicht benutzt“ ab.

Verbindung herstellen

Als erstes muss eine Verbindung zur Datenbank hergestellt werden, was wir gleich in eine eigene Funktion verbinden() auslagern.

func verbinden() *sql.DB {
db, err := sql.Open("sqlite", "Geo.db")
if err != nil {
log.Fatal(err)
}
return db
}

sql.Open öffnet einen Verbindungs-Pool

sql.Open liefert keine einzelne Verbindung zur Datenbank, sondern einen Verbindungspool vom Typ *sql.DB. Ein Pool ist ein Vorrat an Verbindungen, den Go selbst verwaltet: Bei Bedarf werden Verbindungen geöffnet, nach Gebrauch nicht weggeworfen, sondern für die nächste Abfrage wiederverwendet. Das spart das teure Neu-Aufbauen bei jeder einzelnen Anfrage und ist besonders beim Mehrbenutzerbetrieb wichtig.

Wichtig: sql.Open baut noch gar keine Verbindung auf, es prüft nur die Argumente und legt den Pool an. Die erste echte Verbindung entsteht erst bei der ersten Abfrage.

Datei öffnen oder neu anlegen

sql.Open("sqlite", "Geo.db") öffnet eine bestehende Datei Geo.db im Arbeitsverzeichnis – oder legt sie neu und leer an, falls es sie noch nicht gibt. Für diese Anleitung liegt Geo.db bereits vor. Ein Tippfehler im Namen erzeugt deshalb versehentlich eine leere Datenbank, was sich erst später als no such table bemerkbar macht.

main() — Ein erster Test

In main rufen wir verbinden() auf. Die Funktion liefert den fertigen Verbindungs-Pool zurück, den wir in der Variablen db festhalten – ab hier ist db einsatzbereit für alle Abfragen und Befehle.

func main() {
db := verbinden()
defer db.Close() // wird ausgeführt, wenn main endet

// ... hier arbeiten wir mit db ...
} // <- erst HIER läuft db.Close()

Wird das Programm mit go run main.go gestartet und erscheint keine Fehlermeldung, steht die Verbindung – der erste Test ist bestanden.

Warum defer db.Close()?

db.Close() gibt den Pool am Ende wieder frei. Ein mit defer markierter Aufruf wird nicht sofort ausgeführt, sondern aufgeschoben – und zwar genau dann, wenn die umgebende Funktion endet (egal an welcher Stelle oder ob durch return).

So steht das Aufräumen direkt neben dem Öffnen und kann nicht mehr vergessen werden – auch wenn main später mehrere return-Wege oder Fehler hat. Dasselbe Muster nutzen wir gleich für rows.Close().

Struct und Tabellenzeile

Was bei Datenbanken ein Relationenschema ist, entspricht in Go in gewissem Maße dem Struct. Alle Spalten, die ein SELECT-Befehl liefert, werden vorher als Feld in einem Struct angelegt. Für die Relation land, ergibt sich somit folgender neuer Datentyp Land:

type Land struct {
ID int
Name string
Kontinent string
}

Jedes Objekt dieses Typs (eine Land-Variable) steht für genau eine Zeile der Tabelle. Eine ganze Abfrage liefert also viele Land-Werte, die wir in einem Slice []Land sammeln.

Es müssen nicht alle Spalten der Tabelle übernommen werden, nur die, die im SELECT abgefragt werden. Die Feldnamen sind großgeschrieben, weil sie damit in Go nach außen sichtbar (exportiert) sind.

Daten lesen

Jetzt ist alles soweit vorbereitet, um Daten aus der Datenbank auszulesen. Wie beim Verbindungsaufbau auch, wird das Auslesen in ein eigene Funktion ausgelagert:

func landAuslesen(db *sql.DB) []Land {
// Datenbankabfrage
rows, err := db.Query(`
SELECT land_id, name, kontinent
FROM land
ORDER BY name;`)
if err != nil {
log.Fatal(err)
}
// später die Verbindung in den Pool zurücklegen
defer rows.Close()

var laender []Land

// Ergebnis der Abfrage zeilenweise durchlaufen
for rows.Next() {
var l Land
// Solange kein Fehler beim Einlesen einer Zeile
if err := rows.Scan(&l.ID, &l.Name, &l.Kontinent); err != nil {
log.Fatal(err)
}
laender = append(laender, l)
}
// Fehler beim Durchlaufen der Abfrage?
if err := rows.Err(); err != nil {
log.Fatal(err)
}

return laender
}

Was macht db.Query genau?

db.Query schickt das SELECT an die Datenbank und gibt zwei Werte zurück: einen Fehler und einen Zeiger auf ein sql.Rows-Objekt (Typ *sql.Rows). Dieses Objekt ist nicht die fertige Ergebnistabelle, sondern ein ein Lesezeiger, der zu Beginn vor der ersten Zeile steht. Die Datenbank hält das Ergebnis bereit, und wir holen es uns Zeile für Zeile ab.

  • rows.Next() rückt den Cursor eine Zeile weiter und liefert true, solange es noch eine Zeile gibt und false am Ende.
  • rows.Scan(&l.ID, &l.Name, &l.Kontinent) kopiert die Spalten der aktuellen Zeile in die Felder. Anzahl und Reihenfolge der Argumente müssen exakt zur SELECT-Liste passen.
  • rows.Err() nach der Schleife prüft, ob der Durchlauf vorzeitig abgebrochen ist (z. B. wegen eines Netz- oder Lesefehlers).

Warum muss rows.Close() aufgerufen werden?

Solange ein sql.Rows offen ist, hält es eine Verbindung aus dem Pool belegt und bindet Ergebnis-Ressourcen in der Datenbank. Erst rows.Close() gibt beides wieder frei. Wird das vergessen, „leckt“ mit jeder Abfrage eine Verbindung – im Mehrbenutzerbetrieb ist der Pool dann irgendwann leer und neue Anfragen bleiben hängen.

Deshalb steht defer rows.Close() direkt nach der Fehlerprüfung von db.Query: So ist rows garantiert gültig, und der Aufräumschritt läuft zuverlässig, sobald die Funktion endet – egal über welchen Weg.

Werte übergeben – Platzhalter ?

Werte werden nie per String-Verkettung in den SQL-Text geschrieben, sondern immer über ? als zusätzliches Argument an Query übergeben:

rows, err := db.Query(
`SELECT name FROM land WHERE kontinent = ? ORDER BY name;`,
"Europa",
)

Wie wird das ? ersetzt?

Nicht durch Textersetzung in Go. Der SQL-Text mit dem ? und der Wert "Europa" werden getrennt an die Datenbank geschickt. Die Datenbank merkt sich „an dieser Stelle kommt ein Wert“ und setzt ihn dort als reinen Wert ein – niemals als ausführbaren SQL-Code. Genau das verhindert SQL-Injection.

Mehrere Platzhalter werden der Reihe nach mit den folgenden Argumenten gefüllt:

db.Query(
`SELECT name FROM land WHERE kontinent = ? AND bevoelkerung > ?;`,
"Asien", 100_000_000,
)
SQL-Injection

Beim Zusammenbauen per + könnte eine Eingabe wie x'; DROP TABLE land;-- die Datenbank zerstören. Der Platzhalter ? trennt Befehl und Daten sauber. Deshalb immer ? verwenden.

Saubere Ausgabe mit fmt.Printf

Eine Liste liest sich nur gut, wenn die Spalten untereinander stehen. Dafür bekommt fmt.Printf eine Formatvorlage mit Platzhaltern (Verben):

format := "%-8d %-15s %-10s\n"
for _, l := range laender {
fmt.Printf(format, l.ID, l.Name, l.Kontinent)
}

Die Vorlage Stück für Stück:

BestandteilBedeutung
%dgibt eine Ganzzahl aus (von engl. decimal).
%sgibt eine Zeichenkette (string) aus.
8 / 15 / 10Mindestbreite des Feldes in Zeichen; ist der Wert kürzer, wird mit Leerzeichen aufgefüllt → die Spalten fluchten.
-richtet den Wert linksbündig aus (ohne - wäre er rechtsbündig).
\nZeilenumbruch am Ende jeder Zeile.

%-8d heißt also: Ganzzahl, linksbündig, mindestens 8 Zeichen breit. Indem die Vorlage einmal in der Variable format festgehalten wird, nutzt jede Zeile dieselbe Ausrichtung.

Ergebnis (Auszug):

1        Deutschland     Europa
2 USA Nordamerika
3 China Asien

Daten schreiben

Für Befehle ohne Ergebniszeilen (INSERT, UPDATE, DELETE) dient db.Exec. Es liefert ein Result mit zwei nützlichen Methoden: LastInsertId() und RowsAffected(). Auch hier werden Werte über ? übergeben.

Anlegen – INSERT

func landAnlegen(db *sql.DB, name, kontinent string, bevoelkerung int) int64 {
res, err := db.Exec(
`INSERT INTO land (name, kontinent, bevoelkerung)
VALUES (?, ?, ?);`,
name, kontinent, bevoelkerung,
)
if err != nil {
log.Fatal(err)
}

id, _ := res.LastInsertId() // automatisch vergebene land_id
return id
}

Da land_id als INTEGER PRIMARY KEY definiert ist, vergibt SQLite die ID automatisch. Im INSERT wird land_id daher weggelassen. LastInsertId() liefert die neue ID. Das ist besonders dann hilfreich, wenn die ID — zum Beispiel als Fremdschlüssel — sofort benötigt wird.

Ändern – UPDATE

func landAendern(db *sql.DB, name string, bevoelkerung int) int64 {
res, err := db.Exec(
`UPDATE land SET bevoelkerung = ? WHERE name = ?;`,
bevoelkerung, name,
)
if err != nil {
log.Fatal(err)
}

n, _ := res.RowsAffected()
return n
}

RowsAffected() ist zur Kontrolle praktisch: Ist das Ergebnis 0, hat die WHERE-Bedingung auf keine Zeile gepasst (z. B. ein Tippfehler im Namen).

Löschen – DELETE

func landLoeschen(db *sql.DB, name string) int64 {
res, err := db.Exec(
`DELETE FROM land WHERE name = ?;`,
name,
)
if err != nil {
log.Fatal(err)
}

n, _ := res.RowsAffected()
return n
}
WHERE nicht vergessen

UPDATE land SET bevoelkerung = 0; ohne WHERE ändert alle Zeilen – ebenso bei DELETE. Es sollte immer zuerst überlegt werden, welche Zeilen betroffen sein sollen.

Alles zusammen: modularer Aufbau

Statt alles in main zu schreiben, wird das Programm in kleine Funktionen mit klarer Aufgabe zerlegt. Das macht den Code lesbar und wiederverwendbar. Der Pool (db) wird einmal in verbinden() erzeugt und an alle anderen Funktionen als Parameter weitergereicht.

package main

import (
"database/sql"
"fmt"
"log"

_ "modernc.org/sqlite"
)

type Land struct {
ID int
Name string
Kontinent string
}

func verbinden() *sql.DB {
db, err := sql.Open("sqlite", "Geo.db")
if err != nil {
log.Fatal(err)
}
return db
}

func landAuslesen(db *sql.DB) []Land {
rows, err := db.Query(`SELECT land_id, name, kontinent FROM land ORDER BY name;`)
if err != nil {
log.Fatal(err)
}
defer rows.Close()

var laender []Land
for rows.Next() {
var l Land
if err := rows.Scan(&l.ID, &l.Name, &l.Kontinent); err != nil {
log.Fatal(err)
}
laender = append(laender, l)
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
return laender
}

func landAnlegen(db *sql.DB, name, kontinent string, bevoelkerung int) int64 {
res, err := db.Exec(
`INSERT INTO land (name, kontinent, bevoelkerung) VALUES (?, ?, ?);`,
name, kontinent, bevoelkerung,
)
if err != nil {
log.Fatal(err)
}
id, _ := res.LastInsertId()
return id
}

func main() {
db := verbinden()
defer db.Close()

id := landAnlegen(db, "Norwegen", "Europa", 5_400_000)
fmt.Printf("Neues Land angelegt, ID = %d\n", id)

format := "%-8d %-15s %-10s\n"
for _, l := range landAuslesen(db) {
fmt.Printf(format, l.ID, l.Name, l.Kontinent)
}
}

So liest sich main wie eine Inhaltsangabe: verbinden, anlegen, auslesen, ausgeben. Die Details stecken in den Funktionen. Nach diesem Muster lassen sich landAendern und landLoeschen aus Abschnitt 7 ergänzen.

Häufige Fehler

Meldung / SymptomUrsache & Lösung
no required module provides package modernc.org/sqliteIm Projektordner go mod init … und dann go get modernc.org/sqlite ausführen.
sql: unknown driver "sqlite"Der Blank-Import _ "modernc.org/sqlite" fehlt im import-Block.
unable to open database fileGeo.db liegt nicht im Arbeitsverzeichnis. Mit cd in den Projektordner wechseln und go run . dort starten.
database is lockedDie DB ist in einem anderen Programm offen (z. B. DB Browser for SQLite). Dort schließen und erneut versuchen.
Absturz beim Scanerr nach db.Query nicht geprüft, oder Anzahl/Reihenfolge der Scan-Felder passt nicht zur SELECT-Liste.

Referenz database/sql

FunktionWofür
sql.Open(treiber, quelle)Verbindungs-Pool (*sql.DB) öffnen. Treiber: "sqlite", Quelle: Dateiname (öffnet bestehende DB oder legt sie neu an).
db.Query(sql, args…)SELECT*sql.Rows (Cursor), Schleife mit Next/Scan.
db.Exec(sql, args…)INSERT / UPDATE / DELETE → liefert Result.
rows.Next()Cursor zur nächsten Zeile vorrücken.
rows.Scan(&a, &b, …)Spalten der aktuellen Zeile in Variablen kopieren.
rows.Err()Fehler nach der Schleife prüfen.
rows.Close()Ergebnis schließen, Verbindung an den Pool zurückgeben.
res.LastInsertId()Automatisch vergebene ID nach INSERT.
res.RowsAffected()Anzahl betroffener Zeilen nach UPDATE/DELETE.
?Platzhalter für Werte – immer benutzen (Schutz vor SQL-Injection).