How to Join Strings in Go with strings.Join

Use the `strings.Join` function from the standard library to concatenate a slice of strings with a specific separator.

The cost of concatenation

You have a list of user IDs and need to build a comma-separated query string. Or you're generating a CSV row from a struct's fields. The instinct is to loop and append with +. That works for three items. It grinds to a halt for three thousand. Go gives you a tool that calculates the work before it starts.

Strings in Go are immutable. Once created, a string cannot change. When you write a + b, Go allocates a new block of memory, copies a, copies b, and returns the new block. The old a and b stay until the garbage collector finds them. In a loop, this creates a quadratic explosion of allocations. Each iteration copies all previous data again. The memory usage grows as the square of the input size. The garbage collector runs constantly. The CPU cache thrashes.

Think of building a sentence by writing on sticky notes. Every time you add a word, you have to rewrite the entire sentence on a new, larger sheet of paper and throw away the old sheet. strings.Join is like measuring the total length of all words first, grabbing one sheet of the exact right size, and writing everything on it in one pass.

strings.Join avoids this by measuring the total length first. It allocates one buffer, then copies each piece into place. The complexity drops from O(n²) to O(n). The difference is massive for CPU and memory.

How strings.Join works

The function takes a slice of strings and a separator. It iterates the slice once to sum the lengths of all strings plus the separators. It adds that up, allocates a single byte slice of that exact size, then iterates again to copy the data. If the slice is empty, it returns an empty string immediately. No allocation happens for empty input.

Here's the simplest usage: pass a slice and a separator.

package main

import (
	"fmt"
	"strings"
)

func main() {
	// Slice of words to combine
	words := []string{"Go", "is", "fast"}

	// Join measures the total length of all strings plus separators before allocating
	// This single allocation replaces the quadratic cost of repeated concatenation
	result := strings.Join(words, " ")

	fmt.Println(result)
}

Strings are immutable. Measure the total length before you allocate.

Walkthrough: measure, allocate, copy

The implementation follows a strict two-pass pattern. The first pass calculates the required capacity. It sums len(s) for every string in the slice and adds len(separator) * (len(slice) - 1). This math determines the exact size of the output. The function then calls make([]byte, total) to get a contiguous block of memory.

The second pass fills the buffer. It uses copy to move bytes from each string into the destination. copy is a built-in function that efficiently moves memory, often optimized to use low-level instructions. The separator is written between each element. The result is converted back to a string and returned.

This approach guarantees a single allocation for the result, regardless of slice size. The garbage collector sees one object instead of thousands. Memory pressure stays flat. Performance scales linearly with input size.

The strings package is part of the standard library. You don't need to write your own join logic. gofmt will format the code, but the logic choice is yours. Pick the standard library. The community trusts these functions because they are battle-tested and optimized.

Realistic usage: transforming data

Real code rarely joins hardcoded slices. You usually transform data first. strings.Join only accepts []string. If your data is integers or structs, convert them before joining. The compiler enforces this strictly.

Here's a pattern for converting mixed types and joining the result.

package main

import (
	"fmt"
	"strconv"
	"strings"
)

// JoinIDs converts a slice of integers to a comma-separated string
// Pre-allocating the string slice avoids reallocation during the conversion loop
func JoinIDs(ids []int) string {
	// Pre-allocate the string slice to match the input length
	// This prevents the slice from growing dynamically and copying data
	strIDs := make([]string, len(ids))

	for i, id := range ids {
		// Convert each integer to its string representation
		// strconv.Itoa allocates a new string for each value
		strIDs[i] = strconv.Itoa(id)
	}

	// Join the converted strings with a comma separator
	// This allocates memory once for the entire result
	return strings.Join(strIDs, ",")
}

func main() {
	ids := []int{101, 202, 303}
	fmt.Println(JoinIDs(ids))
}

The conversion step allocates a string for each integer. This is unavoidable if you need strings. The win comes from avoiding the O(n²) allocation pattern of concatenation. Pre-allocating the strIDs slice with make ensures the conversion loop doesn't trigger additional slice reallocations.

Transform data, then join. The compiler enforces strict types for a reason.

Pitfalls and type safety

strings.Join handles edge cases gracefully. An empty slice returns an empty string. A nil slice also returns an empty string. This makes it safe to call without checking for nil first. An empty separator "" concatenates strings directly. This is useful for building paths or identifiers. The function does not panic on empty input.

The compiler rejects type mismatches. If you pass a slice of interfaces, you get cannot use values (variable of type []interface{}) as []string value in argument to strings.Join. You must convert the elements to strings first. Another pitfall is assuming the result references the original slice. strings.Join copies the data. Modifying the slice after calling Join has no effect on the result.

Public names start with a capital letter. Join is exported because it's the primary way to combine strings. The strings package follows this convention: exported functions for general use, unexported helpers for internal logic.

The compiler rejects type mismatches. Convert your data explicitly before joining.

Builder versus Join

strings.Join works when you have a collection. strings.Builder works when you build a string piece by piece without a pre-existing slice. Builder is mutable. You call methods like WriteString or WriteByte. When you're done, call String() to get the result. Builder also avoids allocations by growing its internal buffer efficiently. Use Builder when the structure of the output is dynamic or when you are streaming data.

Builder starts with a small buffer and doubles its capacity when full. This amortized growth keeps the cost low, but pre-allocating with Grow is better if you know the size. Builder has a Reset method that clears the content but keeps the buffer. This is useful for reusing the builder in a loop to reduce allocations.

Here's how Builder handles incremental construction.

package main

import (
	"fmt"
	"strings"
)

func main() {
	// Builder grows its internal buffer as needed
	// It avoids creating intermediate strings during construction
	var b strings.Builder

	// Pre-allocate buffer if the approximate size is known
	// This reduces the number of internal reallocations
	b.Grow(100)

	b.WriteString("Header: ")

	// Append items one by one
	for i := 0; i < 5; i++ {
		b.WriteString("item")
		b.WriteByte(',')
	}

	// String returns the final result
	// This allocates only once for the output string
	fmt.Println(b.String())
}

Builder is for streams. Join is for collections.

When to use what

Use strings.Join when you have a slice of strings and a fixed separator.

Use strings.Builder when you are building a string incrementally in a loop where the final content isn't known upfront.

Use fmt.Sprintf when you need complex formatting with a small number of values, like "Name: %s, Age: %d".

Use + when concatenating two or three known string variables outside a loop.

Use bytes.Join when you are working with []byte slices to avoid converting to strings and back.

Don't fight the allocator. Use the right tool.

Where to go next