Das bytes-Paket ist das strukturelle Spiegelbild von strings: jede Such-, Vergleichs- und Trim-Funktion existiert in beiden Varianten — einmal für string, einmal für []byte. Der entscheidende Unterschied liegt unter der Oberfläche: string ist in Go unveränderlich, []byte dagegen frei mutierbar. Wer Daten aus dem Netzwerk, von der Festplatte oder aus einem Subprozess liest, bekommt fast immer []byte zurück — und genau dort beginnt das Revier von bytes.
Neben den klassischen Helfern bringt das Paket zwei zentrale Bausteine mit, die in fast jedem nicht-trivialen Go-Programm auftauchen: bytes.Buffer als universeller Akkumulator, der gleichzeitig io.Writer und io.Reader ist, sowie bytes.Reader als schlanke read-only-Hülle um einen vorhandenen Slice. Dieser Artikel zeigt die vollständige API im Vergleich zu strings, klärt die feinen Unterschiede zu strings.Builder und liefert zwei Praxis-Beispiele — vom JSON-Response-Building bis zur Magic-Byte-Erkennung. Referenz: pkg.go.dev/bytes.
strings vs bytes — die Spiegelbild-API
Wer strings kennt, kennt bytes bereits zur Hälfte. Die beiden Pakete sind über Jahre parallel gepflegt worden und teilen sich nahezu jede Funktionssignatur — nur der Werttyp wechselt von string zu []byte. Die Semantik ist identisch, die Laufzeitcharakteristik ebenfalls ähnlich. Diese Symmetrie ist kein Zufall: sie reflektiert, dass Bytes und Strings in Go zwei Sichten auf dieselben Daten sind, und sie erspart die Aufgabe, zwei verschiedene APIs zu lernen.
| strings | bytes | Bedeutung |
|---|---|---|
strings.Contains | bytes.Contains | Teilstring/-slice enthalten? |
strings.Index | bytes.Index | Position des ersten Vorkommens |
strings.Split | bytes.Split | Aufteilen an Separator |
strings.Join | bytes.Join | Zusammenfügen mit Separator |
strings.HasPrefix | bytes.HasPrefix | Beginnt mit …? |
strings.ToLower | bytes.ToLower | Kleinschreibung (alloziert) |
strings.TrimSpace | bytes.TrimSpace | Whitespace entfernen |
strings.Replace | bytes.Replace | Ersetzen |
strings.EqualFold | bytes.EqualFold | Unicode-case-insensitiv |
Die Auswahl-Faustregel ist pragmatisch: kommt das Datum aus einer I/O-Quelle (io.Reader, http.Response.Body, os.ReadFile), bleibt man bei []byte und arbeitet mit bytes. Stammt das Datum aus einem String-Literal oder einer textorientierten Quelle, nimmt man strings. Konvertierungen zwischen beiden kosten eine Allokation und Kopie — sie sind nicht teuer, aber unnötig, wenn die Pipeline durchgehend im richtigen Typ bleibt.
Compare, Equal, EqualFold
Slice-Vergleich ist in Go ein häufiger Stolperstein: der Operator == ist für Slices schlicht nicht definiert und führt zu einem Compile-Fehler. Das ist eine bewusste Design-Entscheidung — ein impliziter Element-für-Element-Vergleich könnte unbemerkt teuer werden, und die Frage, ob zwei Slices „gleich" sind, hat je nach Kontext unterschiedliche Antworten. Das bytes-Paket macht die Absicht explizit.
bytes.Equal ist die Standardantwort auf „Sind diese beiden Byte-Slices inhaltsgleich?" — sie ist hochoptimiert und in der Regel SIMD-beschleunigt. bytes.Compare liefert wie das klassische C-memcmp die Werte -1, 0 oder +1 für lexikografische Ordnung; das ist die Grundlage für Sortierungen über sort.Slice. bytes.EqualFold führt einen Unicode-fähigen case-insensitiven Vergleich durch und ist die korrekte Wahl für HTTP-Header-Namen oder andere ASCII-/Unicode-Felder.
package main
import (
"bytes"
"fmt"
)
func main() {
a := []byte("hello")
b := []byte("hello")
c := []byte("HELLO")
fmt.Println("Equal :", bytes.Equal(a, b))
fmt.Println("Equal A/C :", bytes.Equal(a, c))
fmt.Println("EqualFold :", bytes.EqualFold(a, c))
fmt.Println("Compare :", bytes.Compare(a, c))
}Equal : true
Equal A/C : false
EqualFold : true
Compare : 1bytes.Equal mit einem nil-Slice und einem leeren Slice ([]byte{}) liefert übrigens true — das Paket behandelt beide als gleichwertig, was sich an die übliche Go-Konvention anlehnt, dass len(nil) == 0 gilt.
Such- und Teil-Funktionen
Das Suchen in Byte-Slices folgt exakt der Logik aus strings. bytes.Contains beantwortet die Ja/Nein-Frage, bytes.Index liefert die Position des ersten Vorkommens (oder -1), bytes.LastIndex sucht von hinten. Für Aufteilungen stehen bytes.Split (alle Vorkommen) und bytes.SplitN (begrenzte Anzahl) bereit; die Umkehrung erledigt bytes.Join mit einem Separator-Slice.
package main
import (
"bytes"
"fmt"
)
func main() {
data := []byte("name=Anna&age=30&city=Berlin")
fmt.Println("Contains '=':", bytes.Contains(data, []byte("=")))
fmt.Println("Index 'age' :", bytes.Index(data, []byte("age")))
fmt.Println("HasPrefix :", bytes.HasPrefix(data, []byte("name=")))
parts := bytes.Split(data, []byte("&"))
for _, p := range parts {
fmt.Printf(" -> %s\n", p)
}
joined := bytes.Join(parts, []byte(" | "))
fmt.Printf("Joined: %s\n", joined)
}Contains '=': true
Index 'age' : 10
HasPrefix : true
-> name=Anna
-> age=30
-> city=Berlin
Joined: name=Anna | age=30 | city=BerlinEin wichtiger Hinweis zu bytes.Split: die zurückgegebenen Sub-Slices teilen sich den Backing-Array des Eingabe-Slices. Wer einen der Teile dauerhaft aufbewahren und gleichzeitig den Quell-Slice modifizieren oder zur Wiederverwendung freigeben will, muss explizit kopieren — append([]byte(nil), part...) oder bytes.Clone(part) ab Go 1.20.
Trim-Familie
Das Entfernen unerwünschter Zeichen am Anfang oder Ende eines Slices ist die wahrscheinlich häufigste Vorverarbeitung beim Parsen von Eingabedaten. bytes.TrimSpace entfernt Unicode-Whitespace (Leerzeichen, Tabs, Zeilenumbrüche, geschützte Leerzeichen) von beiden Seiten — und ist die richtige Wahl, wenn man eine Zeile aus einer Datei oder ein HTTP-Header-Value bereinigt.
Feinkörniger arbeiten bytes.Trim (entfernt eine beliebige Zeichenmenge), bytes.TrimLeft/TrimRight (nur eine Seite), bytes.TrimPrefix/TrimSuffix (entfernt eine konkrete Sequenz, falls vorhanden — sonst Original) und bytes.TrimFunc (Prädikatsfunktion pro Rune). Die TrimPrefix-Variante ist besonders elegant, weil sie idempotent ist und keinen Fehler wirft, wenn das Präfix gar nicht da ist.
package main
import (
"bytes"
"fmt"
"unicode"
)
func main() {
line := []byte(" \t Hallo, Welt! \n")
fmt.Printf("[%s]\n", bytes.TrimSpace(line))
noise := []byte("###Header###")
fmt.Printf("[%s]\n", bytes.Trim(noise, "#"))
url := []byte("https://mibeon.de/docs/go")
fmt.Printf("[%s]\n", bytes.TrimPrefix(url, []byte("https://")))
digits := []byte("abc123def")
clean := bytes.TrimFunc(digits, func(r rune) bool {
return !unicode.IsDigit(r)
})
fmt.Printf("[%s]\n", clean)
}[Hallo, Welt!]
[Header]
[mibeon.de/docs/go]
[123]bytes.Buffer — der Standard-Akkumulator
bytes.Buffer ist der wahrscheinlich am häufigsten verwendete Typ aus der gesamten Standardbibliothek nach string selbst. Er ist ein wachsender Byte-Speicher, der gleichzeitig als Schreibziel (implementiert io.Writer, io.ByteWriter, io.StringWriter, io.ReaderFrom) und als Lesequelle (implementiert io.Reader, io.ByteReader, io.WriterTo) dient. Damit passt er an jede Stelle, an der die Standardbibliothek einen io.Writer oder io.Reader erwartet — und das sind praktisch alle Encoder, Template-Engines und Kompressoren.
Die Schreib-Methoden decken alle gängigen Fälle ab: Write für rohe Slices, WriteString ohne Konvertierungs-Allokation, WriteByte für einzelne Bytes, WriteRune für Unicode-Codepoints, WriteTo zum Abkippen in einen anderen Writer und ReadFrom zum Einsaugen aus einem Reader. Der Zugriff auf das Ergebnis erfolgt über Bytes() (interner Slice, kein Copy) oder String() (Kopie als String).
package main
import (
"bytes"
"compress/gzip"
"fmt"
"text/template"
)
func main() {
// 1) Als io.Writer für text/template
var buf bytes.Buffer
tpl := template.Must(template.New("greet").Parse("Hallo, {{.Name}}!\n"))
_ = tpl.Execute(&buf, map[string]string{"Name": "Welt"})
fmt.Print(buf.String())
// 2) Als io.Writer hinter gzip.NewWriter
var gz bytes.Buffer
w := gzip.NewWriter(&gz)
_, _ = w.Write([]byte("komprimier mich"))
_ = w.Close()
fmt.Printf("gzip-Länge: %d Bytes\n", gz.Len())
}Hallo, Welt!
gzip-Länge: 43 BytesWichtig: der Nullwert var buf bytes.Buffer ist sofort einsatzbereit — kein NewBuffer-Aufruf nötig. Bei bekannter Endgröße spart buf.Grow(n) mehrere Realloc-Schritte und ist die Optimierung der ersten Wahl, bevor man zu Mikro-Tuning greift.
bytes.Buffer vs strings.Builder
Beide Typen lösen das gleiche Grundproblem — viele kleine Schreibvorgänge zu einem großen Stück zusammenfügen — aber sie zielen auf unterschiedliche Use Cases. strings.Builder (eingeführt in Go 1.10) ist ein bewusst minimalistischer Akkumulator: append-only, kein Mutex, garantiert keine Kopie beim finalen String()-Aufruf. Er ist messbar schneller als bytes.Buffer, wenn das Endergebnis ein string sein soll.
bytes.Buffer ist dagegen bidirektional: man kann nicht nur schreiben, sondern den Inhalt auch wieder als io.Reader herauslesen, in einen anderen Writer kippen oder partiell konsumieren. Das macht ihn zur natürlichen Wahl, wenn der Buffer als Vermittler in einer Pipeline dient — etwa als Sammelpunkt zwischen einem Encoder und einem HTTP-Response.
| Aspekt | bytes.Buffer | strings.Builder |
|---|---|---|
| Endprodukt | []byte und string | nur string |
| Lesen vom eigenen Inhalt | ja (io.Reader) | nein |
| Geschwindigkeit | gut | besser (keine Internkonsistenz-Checks) |
| Copy beim Konvertieren | ja (String() kopiert) | nein (direktes Übergeben des Internals) |
| Kann zurückgesetzt werden | Reset() | Reset() |
| Use Case | I/O-Pipelines, json.Encoder | String-Konkatenation in Schleifen |
Die Faustregel: für reines String-Bauen (Logzeile zusammensetzen, SQL-Query konkatenieren) ist strings.Builder erste Wahl. Sobald irgendwo ein io.Writer-Interface, ein []byte-Ergebnis oder bidirektionales Lesen ins Spiel kommt, ist bytes.Buffer der richtige Typ.
bytes.Reader
Wenn man bereits einen []byte in der Hand hat und ihn an eine API übergeben muss, die einen io.Reader erwartet, ist bytes.NewReader die kompromisslos beste Wahl. Anders als bytes.NewBuffer (das einen vollwertigen Buffer mit Schreibfähigkeit zurückgibt) erzeugt bytes.NewReader einen schlanken, unveränderlichen Wrapper — ohne Kopie der Daten und ohne den Overhead einer mutierbaren Datenstruktur.
bytes.Reader implementiert mehr Interfaces als bytes.Buffer: io.Reader, io.ReaderAt (positionierte Reads), io.Seeker (Springen im Stream), io.ByteReader, io.RuneReader und io.WriterTo. Das macht ihn zur einzig sinnvollen Wahl für APIs, die suchbare Streams brauchen — etwa archive/zip.NewReader, das einen io.ReaderAt benötigt, oder image.Decode mit Header-Probe.
package main
import (
"bytes"
"fmt"
"io"
)
func main() {
data := []byte("0123456789ABCDEF")
r := bytes.NewReader(data)
chunk := make([]byte, 4)
_, _ = r.Read(chunk)
fmt.Printf("Erste 4: %s\n", chunk)
_, _ = r.Seek(0, io.SeekStart)
_, _ = r.Read(chunk)
fmt.Printf("Nach Seek: %s\n", chunk)
at := make([]byte, 4)
_, _ = r.ReadAt(at, 10)
fmt.Printf("Ab Pos 10: %s\n", at)
fmt.Printf("Restlänge: %d\n", r.Len())
}Erste 4: 0123
Nach Seek: 0123
Ab Pos 10: ABCD
Restlänge: 12Sicherheitshinweis: bytes.NewReader(b) macht keine Kopie. Wer den Source-Slice b nach der Reader-Erstellung modifiziert, liest beim nächsten Read die neuen Bytes. Das ist meist gewünscht (es spart Allokationen), aber bei nebenläufigem Zugriff oder bei wiederverwendeten Buffern eine Falle.
bytes.Cut, CutPrefix, CutSuffix
Seit Go 1.18 (Cut) und Go 1.20 (CutPrefix, CutSuffix) gibt es eine elegantere Alternative zum klassischen Split-Pattern, wenn man genau am ersten Vorkommen eines Separators trennen will. bytes.Cut(s, sep) liefert drei Werte: den Teil vor dem Separator, den Teil danach und einen Boolean, der angibt, ob der Separator gefunden wurde. Damit verschwinden zwei lästige Muster auf einmal: das SplitN(..., 2) mit Index-Check und die separate Suche per Index mit anschließender Slice-Arithmetik.
package main
import (
"bytes"
"fmt"
)
func main() {
header := []byte("Content-Type: application/json")
key, value, ok := bytes.Cut(header, []byte(": "))
if !ok {
fmt.Println("kein Separator")
return
}
fmt.Printf("Key: %s\n", key)
fmt.Printf("Value: %s\n", value)
if rest, ok := bytes.CutPrefix(value, []byte("application/")); ok {
fmt.Printf("MIME-Subtype: %s\n", rest)
}
}Key: Content-Type
Value: application/json
MIME-Subtype: jsonCutPrefix und CutSuffix sind die natürliche Ergänzung zu TrimPrefix/TrimSuffix: sie liefern zusätzlich die Information, ob das Präfix/Suffix tatsächlich vorhanden war — was den separaten HasPrefix-Check spart.
JSON-Response mit bytes.Buffer bauen
Ein typischer HTTP-Handler in Go: Daten serialisieren, Content-Length setzen, Body schreiben. Wenn die Größe der Antwort vorab bekannt sein soll (für Content-Length oder weil der ResponseWriter keine zweite Chance gibt), baut man die Antwort zuerst in einen bytes.Buffer und schreibt sie erst dann raus. Das ist auch das Standard-Pattern, wenn Fehler beim Marshalling zu einem 500 führen sollen, statt die halbe Antwort an den Client gesendet zu haben.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func usersHandler(w http.ResponseWriter, r *http.Request) {
users := []User{
{1, "Anna", "anna@example.de"},
{2, "Bernd", "bernd@example.de"},
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
if err := enc.Encode(users); err != nil {
http.Error(w, "encoding failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
w.WriteHeader(http.StatusOK)
_, _ = buf.WriteTo(w)
}
func main() {
req := httptest.NewRequest("GET", "/users", nil)
rec := httptest.NewRecorder()
usersHandler(rec, req)
fmt.Println("Status:", rec.Code)
fmt.Println("Len :", rec.Header().Get("Content-Length"))
fmt.Println(rec.Body.String())
}Status: 200
Len : 121
[
{
"id": 1,
"name": "Anna",
"email": "anna@example.de"
},
{
"id": 2,
"name": "Bernd",
"email": "bernd@example.de"
}
]Der entscheidende Punkt ist die Trennung von Encoding und Transport. Würde man direkt mit json.NewEncoder(w).Encode(users) arbeiten, ginge im Fehlerfall ein halb-geschriebener Body raus, der Status-Header wäre bereits committet, und der Client bekäme ein kaputtes JSON statt eines sauberen 500. Der bytes.Buffer dient als Sicherheitsnetz und als Längen-Messer in einem.
Magic-Byte-Erkennung für Dateitypen
Beim Verarbeiten hochgeladener Dateien ist der vom Client gesendete MIME-Type unzuverlässig — er ist trivial zu fälschen und basiert oft nur auf der Dateiendung. Der robuste Ansatz ist die Inspektion der ersten Bytes: praktisch jedes Binärformat hat eine charakteristische Signatur am Anfang. bytes.HasPrefix ist genau das richtige Werkzeug dafür.
package main
import (
"bytes"
"fmt"
)
type FileType struct {
Name string
Magic []byte
}
var signatures = []FileType{
{"PNG", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}},
{"JPEG", []byte{0xFF, 0xD8, 0xFF}},
{"GIF", []byte("GIF8")},
{"PDF", []byte("%PDF-")},
{"ZIP", []byte{'P', 'K', 0x03, 0x04}},
{"GZIP", []byte{0x1F, 0x8B}},
}
func detect(header []byte) string {
for _, sig := range signatures {
if bytes.HasPrefix(header, sig.Magic) {
return sig.Name
}
}
return "unbekannt"
}
func main() {
samples := map[string][]byte{
"upload1.bin": {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00},
"upload2.bin": {0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10},
"upload3.bin": []byte("%PDF-1.7\n%..."),
"upload4.bin": []byte("Hallo Welt"),
}
for name, data := range samples {
fmt.Printf("%-12s -> %s\n", name, detect(data))
}
}upload1.bin -> PNG
upload2.bin -> JPEG
upload3.bin -> PDF
upload4.bin -> unbekanntIn der Standardbibliothek erledigt net/http.DetectContentType diese Aufgabe für die gängigsten Formate bereits fertig — die manuelle Variante mit bytes.HasPrefix ist aber lehrreich und unschlagbar, wenn man eigene oder seltene Formate ergänzen will. Das Muster ist universell und taucht in fast jedem Datei-Verarbeitungs-Workflow auf: Header lesen, Signatur prüfen, abhängig vom Typ den richtigen Decoder wählen.
Interessantes
bytes.Buffer als universeller io.Writer
Wo immer eine API einen io.Writer erwartet und das Ergebnis als []byte gebraucht wird, ist bytes.Buffer die Standardantwort — von gzip.NewWriter über json.NewEncoder bis template.Execute.
bytes.Reader statt NewBuffer für read-only
bytes.NewReader(b) ist die richtige Wahl für read-only-Use-Cases — schlanker, sicherer, implementiert zusätzlich io.Seeker und io.ReaderAt. bytes.NewBuffer nur, wenn echte Schreibfähigkeit gebraucht wird.
strings.Builder schneller für reine Strings
Geht es ausschließlich darum, Strings zu konkatenieren, ist strings.Builder messbar schneller — keine Allokation beim finalen String(), kein bidirektionaler Overhead.
Slice-Compare via == ist Compile-Fehler
[]byte lässt sich in Go nicht mit == vergleichen — der Compiler weist das ab. bytes.Equal ist die explizite, optimierte Alternative.
bytes-Funktionen spiegeln strings 1:1
Wer strings kennt, kennt bytes — die Namen, Argumente und Semantiken sind identisch. Es gibt nicht zwei APIs zu lernen, nur einen Typ-Tausch.
Buffer.Bytes() liefert internen Slice
buf.Bytes() gibt einen Slice auf den internen Speicher zurück — nicht kopieren ist Absicht und meist gewünscht. Aber: weiteres Schreiben in den Buffer kann den Backing-Array umallozieren und den Slice invalidieren.
bytes.NewReader kopiert nicht
bytes.NewReader(b) teilt sich den Backing-Array mit b. Modifikationen am Quell-Slice spiegeln sich beim Lesen wider — sparsam, aber bei nebenläufigem Zugriff eine Falle.
io.Copy schlägt manuelle Loops
Beim Übertragen aus einem bytes.Buffer in einen anderen Writer ist buf.WriteTo(w) oder io.Copy(w, &buf) schneller als eine handgeschriebene Schleife — die Standardbibliothek nutzt interne Fast-Paths.