How to concatenate strings in Go
For a fixed handful of pieces, just use +: full := first + second. Building a string in a loop? Use strings.Builder — b.WriteString(part) then b.String(). Already holding a []string? strings.Join(parts, ", "). Never s += part in a loop: that copies O(n²) bytes.
Go strings are immutable, so nothing ever appends to a string in place — every concatenation allocates a new string and copies both halves into it. For two or three pieces that single copy is cheap and + is the clearest code you can write. The moment the number of pieces grows with your data, the same operator turns quadratic, and Go gives you three purpose-built answers instead: strings.Builder, strings.Join, and bytes.Buffer. This page shows all of them, what each one actually allocates, and how to pick. Each example runs on this page — hit Run, then edit the code and run it again.
1The + and += operatorsRecommended
+ is Go's only concatenation operator — there is no ., no .., and no overloading. Both operands must already be strings, and the result is a brand-new string. a += b is exactly a = a + b. For a known, small number of parts this is the fastest and most readable option there is; the compiler even folds constant literals together at build time.
Output
Prints CompileBytes, Hello, CompileBytes!, concatenation is just a copy, then port 8080. Two things bite newcomers here. First, line breaks: Go's automatic semicolon insertion means the + has to end the line, never start the next one. Second, types — mixing in a number is a compile error, not a silent conversion, so reach for strconv.Itoa(n) or fmt.Sprint(n) (see converting between strings and ints).
2Why += in a loop is O(n²)
A Go string value is a two-word header — a pointer to bytes and a length — and those bytes are read-only. s[0] = 'G' does not compile. So s += "x" can't extend anything: it allocates a buffer of len(s)+1 bytes, copies the old string in, appends the new byte, and points s at the result. Do that in a loop and iteration i copies i bytes — the total is n(n+1)/2, quadratic in the length of the output. The snippet below counts those copied bytes instead of timing anything, so the number is exact and identical on every run.
Output
The first line prints go Go — the original is untouched, because []byte(s) made a copy. Then the numbers: final length: 1000 but bytes copied: 500500, i.e. 500× more memory traffic than the result, plus a thousand allocations for the garbage collector to clean up. The builder produces builder length: 1000 and same string: true from a single buffer. Push the loop to 100,000 and += copies 5 billion bytes while the builder still copies 100,000.
3strings.Builder for loops
strings.Builder (Go 1.10+) accumulates bytes in one growable buffer and hands you the finished string with String() — with no copy at the end, because the builder guarantees nobody else holds the buffer. Its zero value is ready to use, so var b strings.Builder is the whole setup. Grow(n) reserves n bytes up front, turning the amortised handful of reallocations into exactly one; the argument is a byte count, not a character count.
Output
Prints go is fast!, len: 11, then build-42. The four write methods cover everything: WriteString, WriteByte, WriteRune (which encodes to UTF-8 for you) and Write([]byte), and none of them can fail — the returned error is always nil. Two rules: pass a pointer when you hand a builder to another function or to fmt.Fprintf, and never copy a builder after first use — b2 := b panics with illegal use of non-zero Builder copied by value on the next write.
4strings.Join for a slice
When the pieces are already in a []string, stop writing loops: strings.Join(parts, sep) sums the lengths, allocates exactly once, and copies each element in. It is the fastest way to glue a slice together and it gets the separator logic right — the separator goes between elements, so there is no trailing comma to trim and a one-element slice comes back unchanged.
Output
Prints usr/local/bin, usrlocalbin, usr, "" and 1, 2, 3. A nil or empty slice yields the empty string rather than panicking, and an empty separator makes Join a plain concatenate-everything. The catch is the signature: Join only accepts []string, so ints, floats and structs need a conversion pass first — make the destination at the right length and index into it, never append to a slice you already sized.
5fmt.Sprintf when you are formatting
fmt.Sprintf returns a formatted string instead of printing it, and it is the right tool when the job is formatting — interpolating numbers, padding columns, quoting values — rather than gluing strings. It pays for that with reflection on every argument, which makes it roughly an order of magnitude slower than +. Use it for templates and messages; don't use it to bolt two strings together in a hot loop.
Output
Prints api listening on :8080, path="/usr/bin" after 1.50s, then "ab1 2c" and "a 1\n". That third line is the one people get wrong: fmt.Sprint inserts a space only when neither neighbour is a string, which is why a and b run together but 1 and 2 do not. If you want predictable output, spell it out with Sprintf or + instead of relying on that rule.
6bytes.Buffer and append([]byte)
If the destination is bytes — an HTTP body, a file, anything behind an io.Writer — build a []byte and skip the string entirely. bytes.Buffer is the read/write cousin of strings.Builder: same zero-value-ready API, plus Read, Bytes() and WriteTo. Lower still, append on a plain []byte needs no type at all, and the strconv.Append* family formats numbers straight into your buffer with zero intermediate strings.
Output
Prints GET /health, bytes: 12, status=200 ok and 13 32 — thirteen bytes used of the thirty-two reserved by make, so nothing had to grow. Note append(out, "status="...): Go special-cases spreading a string into a []byte append, no conversion needed. The trade-off versus strings.Builder is one copy: buf.String() allocates a fresh string, because a Buffer can still be written to afterwards.
7Which should you use?
| Method | Allocations | Speed | Best for |
|---|---|---|---|
| a + b | One per + | Fastest | A fixed handful of pieces |
| s += part (in a loop) | One per iteration | Slowest — O(n²) | Nothing; use a Builder |
| strings.Builder | One with Grow | Fast | Building in a loop |
| strings.Join | Exactly one | Fastest | A []string plus a separator |
| fmt.Sprintf | Several | Slow — reflection | Formatting, not gluing |
| bytes.Buffer / append | Amortised | Fast | Byte output and io.Writer |
Frequently asked questions
What is the fastest way to concatenate strings in Go?
It depends on the shape of the problem, not on a single winner. For a fixed, small number of pieces, a + b + c is fastest — the compiler knows every length and allocates once. For a slice you already have, strings.Join(parts, sep) also allocates exactly once. For anything built up in a loop, strings.Builder with Grow is the right answer. fmt.Sprintf is the slowest of the four because it reflects over every argument.
Why is using += in a Go loop slow?
Because Go strings are immutable, s += part cannot extend s — it allocates a new buffer, copies the whole existing string into it, and appends the new part. Iteration i therefore copies i bytes, so building n bytes copies n(n+1)/2 in total. Building a 1000-byte string that way copies 500,500 bytes and makes 1000 allocations; a strings.Builder copies 1000 bytes into one buffer.
Can I concatenate a string and an int in Go?
Not with + — "port " + 8080 is a compile error, because Go has no implicit conversions. Convert the number first with strconv.Itoa(n) (fastest, ints only), strconv.FormatInt/FormatFloat for other types, or fmt.Sprint(n) for anything. If you are formatting a whole message anyway, fmt.Sprintf("port %d", n) is the readable choice.
strings.Builder or bytes.Buffer — which should I use?
Use strings.Builder when the result is a string: its String() returns the buffer without copying, because a Builder is write-only and cannot be aliased. Use bytes.Buffer when you need to read back what you wrote, hand out Bytes(), or use it as both an io.Reader and an io.Writer. Both are ready to use as their zero value and both implement io.Writer.
Do I have to call Grow on a strings.Builder?
No — a Builder grows automatically, roughly doubling, so the reallocations are amortised O(1). Grow(n) just collapses those into a single allocation when you can estimate the final byte size, for example b.Grow(len(sep)*(len(parts)-1) + total). It takes a byte count, not a character count, and over-estimating only wastes memory.
How do I join a slice of strings with a separator in Go?
strings.Join(parts, ", ") — it puts the separator between elements only, so there is no trailing separator to trim, a one-element slice comes back unchanged, and a nil or empty slice returns "". Join only accepts []string, so convert other element types first, for instance by filling a make([]string, len(nums)) with strconv.Itoa.