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.
modernc.org/sqliteWir 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.modunterrequire(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.
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
- Download der Datenbank: Geo.db
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, derdatabase/sqlmit 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 lieferttrue, solange es noch eine Zeile gibt undfalseam 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 zurSELECT-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,
)
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:
| Bestandteil | Bedeutung |
|---|---|
%d | gibt eine Ganzzahl aus (von engl. decimal). |
%s | gibt eine Zeichenkette (string) aus. |
8 / 15 / 10 | Mindestbreite 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). |
\n | Zeilenumbruch 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 vergessenUPDATE 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 / Symptom | Ursache & Lösung |
|---|---|
no required module provides package modernc.org/sqlite | Im 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 file | Geo.db liegt nicht im Arbeitsverzeichnis. Mit cd in den Projektordner wechseln und go run . dort starten. |
database is locked | Die DB ist in einem anderen Programm offen (z. B. DB Browser for SQLite). Dort schließen und erneut versuchen. |
Absturz beim Scan | err nach db.Query nicht geprüft, oder Anzahl/Reihenfolge der Scan-Felder passt nicht zur SELECT-Liste. |
Referenz database/sql
| Funktion | Wofü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). |