strings.Join ist die kanonische Art, ein []string mit einem Trenner zu einem einzigen String zu verschmelzen. Die Funktion läuft intern in zwei Phasen: zuerst wird die Gesamtlänge des Ergebnis-Strings aus den Längen der Eingabe-Elemente und des Trenners berechnet, dann wird eine einzige Backing-Allocation in passender Größe angelegt und befüllt. Damit ist Join deutlich schneller als ein naiver for-Loop mit s += elem, der bei jeder Iteration einen neuen String alloziert und kopiert (quadratisches Verhalten), und meist auch schneller als ein strings.Builder ohne vorab gesetztes Grow, weil dort die internen Buffer-Verdopplungen mehrfach umkopieren. Als Inverse zu strings.Split schließt Join den Kreis: ein zerlegter Slice lässt sich mit dem ursprünglichen Trenner wieder zur Originalzeichenkette zusammensetzen.
Die Signatur ist minimalistisch: Slice rein, Trenner rein, String raus. Es gibt keinen Fehlerwert, weil die Operation rein wertbasiert ist und keine externen Ressourcen anfasst.
func Join(elems []string, sep string) stringBeide Parameter sind reine Werte: elems wird nur gelesen, der Trenner sep ebenfalls. Da Strings in Go immutable sind, kann Join keinen Seiteneffekt auf die Eingabe haben — das Ergebnis ist immer ein frisch allozierter neuer String.
Drei Sonderfälle sind im Alltag besonders relevant und werden von Join konsistent behandelt: ein leeres Slice ergibt einen leeren String, ein Slice mit genau einem Element gibt dieses Element unverändert (ohne Trenner) zurück, und ein leerer Trenner führt zu reiner Konkatenation der Elemente. Das macht Join als Baustein berechenbar — keine Sonderlogik für „Trenner nur zwischen Elemente, nicht am Rand" nötig.
package main
import (
"fmt"
"strings"
)
func main() {
// Leeres Slice -> leerer String
fmt.Printf("%q\n", strings.Join([]string{}, ", "))
// Genau ein Element -> Element ohne Trenner
fmt.Printf("%q\n", strings.Join([]string{"allein"}, ", "))
// Leerer Trenner -> reine Konkatenation
fmt.Printf("%q\n", strings.Join([]string{"a", "b", "c"}, ""))
// Normalfall
fmt.Printf("%q\n", strings.Join([]string{"a", "b", "c"}, ", "))
}""
"allein"
"abc"
"a, b, c"Beachtenswert ist, dass Join bei einem Element den Trenner nicht voran- oder nachstellt. Wer ein Element mit garantiertem Suffix-Trenner braucht (z. B. „jede Zeile endet mit \n"), kombiniert besser Join mit einem expliziten Append des Trenners am Ende, oder nutzt direkt einen strings.Builder.
Der Witz von Join steckt in der internen Vorausberechnung. Bevor irgendetwas geschrieben wird, summiert die Funktion die Längen aller Elemente plus (len(elems)-1) * len(sep) und alloziert dann einen []byte exakt dieser Größe. Anschließend werden die Bytes mit einer einzigen Schleife aus copy-Aufrufen befüllt. Das Ergebnis: eine einzige Heap-Allocation für das gesamte Resultat, unabhängig davon wie viele Elemente das Slice enthält.
Im Gegensatz dazu erzeugt eine naive Konkatenationsschleife mit += bei jedem Schritt einen komplett neuen String, weil Strings immutable sind. Bei n Elementen sind das O(n) Allokationen und O(n²) kopierte Bytes — bei 10 000 Strings ein Performance-Desaster, bei 100 Mio. ein OOM-Risiko.
package main
import (
"fmt"
"strings"
)
func main() {
elems := []string{"Hund", "Katze", "Maus", "Pferd"}
// Eine Allokation, vorausberechnete Größe
result := strings.Join(elems, ", ")
fmt.Println(result)
}Hund, Katze, Maus, PferdWer auf maximalem Durchsatz arbeitet — etwa beim Schreiben von Log-Lines oder beim Generieren großer SQL-Statements — sollte Join als Default-Werkzeug betrachten. Erst wenn das Konkatenationsmuster dynamisch wird (Konditionale, gemischte Typen, formatierte Werte), lohnt der Wechsel zu strings.Builder mit Grow.
Die drei gängigen Wege haben sehr unterschiedliche Allokations-Profile. Die folgende Übersicht macht klar, warum Join der Default für „Slice → String mit Trenner" ist:
| Ansatz | Allokationen | Komplexität | Wann sinnvoll |
|---|---|---|---|
strings.Join(elems, sep) | 1 | O(n) | Slice steht bereits, einheitlicher Trenner |
strings.Builder + Grow(total) | 1 (wenn Grow korrekt gesetzt) | O(n) | Dynamischer Aufbau, gemischte Typen |
strings.Builder ohne Grow | log₂(n) (Verdopplungen) | O(n) | Größe unbekannt, mittelgroße Strings |
s += elem im Loop | n | O(n²) | Niemals für mehr als eine Handvoll Elemente |
Join gewinnt diesen Vergleich, sobald das Slice bereits existiert und der Trenner statisch ist. Sobald der Aufbau bedingt wird („füge X nur an wenn Y") oder andere Typen mitspielen (int, time.Time), wandert man zum Builder — der +=-Weg bleibt in produktivem Code praktisch immer ein Bug.
Join und Split sind zueinander invers, solange Split keine Felder verwirft. Das gilt für strings.Split, das auch leere Teilfelder behält — bei SplitN mit Limit oder bei eigenen Filter-Schritten geht die Inverse-Eigenschaft verloren.
package main
import (
"fmt"
"strings"
)
func main() {
original := "a,b,,d"
parts := strings.Split(original, ",")
roundtrip := strings.Join(parts, ",")
fmt.Printf("original: %q\n", original)
fmt.Printf("parts: %#v\n", parts)
fmt.Printf("roundtrip: %q\n", roundtrip)
fmt.Println("gleich? ", original == roundtrip)
}original: "a,b,,d"
parts: []string{"a", "b", "", "d"}
roundtrip: "a,b,,d"
gleich? trueDas leere Feld zwischen b und d wird von Split als Leerstring im Slice abgelegt und von Join korrekt wieder zwischen die beiden Trenner gesetzt. Diese Eigenschaft ist wertvoll für Normalisierungs-Pipelines: man kann zerlegen, gezielt Elemente transformieren und ohne Informationsverlust wieder zusammensetzen.
Für simple, durchgängig zahlen- oder ID-lastige Zeilen ohne Quoting-Bedarf ist Join die kompakteste Lösung, eine CSV-Zeile zu bauen. Achtung: Sobald Felder Kommas, Anführungszeichen oder Zeilenumbrüche enthalten könnten, ist das kein korrektes CSV mehr — dann gehört das Paket encoding/csv her, das Quoting und Escaping nach RFC 4180 übernimmt.
package main
import (
"fmt"
"strings"
)
func main() {
header := []string{"id", "name", "rolle"}
zeile1 := []string{"42", "Anna", "Admin"}
zeile2 := []string{"43", "Ben", "User"}
fmt.Println(strings.Join(header, ","))
fmt.Println(strings.Join(zeile1, ","))
fmt.Println(strings.Join(zeile2, ","))
}id,name,rolle
42,Anna,Admin
43,Ben,UserIn produktiven Exporten lohnt sich der Reflex, schon beim ersten Feld mit unsicherer Herkunft auf encoding/csv umzustellen. Join bleibt aber unschlagbar für interne Tools, Debug-Dumps oder Konfig-Dateien, wo der Inhalt unter eigener Kontrolle liegt.
Für reine POSIX-Pfade (/-getrennt) reicht strings.Join aus, etwa beim Aufbau von URL-Pfaden oder Routen, die nichts mit dem lokalen Dateisystem zu tun haben. Für Dateisystempfade ist path/filepath.Join die richtige Wahl — es nutzt das plattformspezifische Trennzeichen und normalisiert nebenher doppelte Slashes weg.
package main
import (
"fmt"
"strings"
)
func main() {
segmente := []string{"api", "v1", "users", "42"}
// URL-/Routen-Pfad: immer /
urlPath := "/" + strings.Join(segmente, "/")
fmt.Println(urlPath)
// Für Dateisystem-Pfade besser filepath.Join verwenden
// (berücksichtigt \ auf Windows und normalisiert)
}/api/v1/users/42Die Trennung der beiden Welten ist eine produktive Disziplin: strings.Join für logische Konkatenation mit fixem Separator, filepath.Join für Pfade auf der konkreten Maschine. Wer beides vermischt, baut sich Windows-Bugs ein, die auf macOS und Linux nie auftreten.
Eine Allokation
strings.Join berechnet die Zielgröße vorab und alloziert genau einen Buffer — unabhängig von der Anzahl der Elemente.
Leeres Slice ergibt leeren String
Ein leeres []string produziert einen leeren String, ohne Fehler und ohne Trenner-Artefakte am Rand.
Ein Element bleibt nackt
Bei genau einem Element wird der Trenner gar nicht angefasst — das Element kommt unverändert zurück.
Leerer Trenner = reine Konkatenation
Mit leerem sep arbeitet Join als reine Konkatenations-Funktion — nützlich, um ein []string zu einem einzelnen String zu verkleben.
Inverse zu Split
Join(Split(s, sep), sep) reproduziert s exakt, solange Split alle (auch leeren) Felder behält.
Schneller als +=-Loop
Naive s += elem-Schleifen sind O(n²) in den kopierten Bytes — Join ist O(n) und vermeidet dieses Profil komplett.
Schneller als Builder ohne Grow
Ein strings.Builder ohne vorab gesetztes Grow reallokiert beim Verdoppeln mehrfach — Join kennt die Endgröße sofort.
Threadsafe auf der Eingabe
Join liest das Slice nur und verändert weder Elemente noch Trenner — parallele Aufrufe auf dasselbe Slice sind unkritisch.
Weiterführende Ressourcen
Externe Quellen
strings.Joinfilepath.Joinencoding/csvfür echtes CSV