5 Ways to Perform String Concatenation in Go

Strings are one of the building blocks of any programming language, and joining two or more strings together is an operation that you are sure to perform often. There are several possible methods for doing this in Go, each with their own unique trade-offs which will be considered in this article.

1. String concatenation using the + operator

The easiest way to concatenate strings in Go is by using the concatenation operator (+). Here’s an example:

func main() {
	name := "John"
	email := "john.doe@gmail.com"
	s := name + "'s" + " email address is " + email
	fmt.Println(s)
}
output
John's email address is john.doe@gmail.com

For basic string concatenation operations such as the one shown above, this should suffice. However, it can be inefficient for larger operations such when concatenating several large strings in a loop. Strings are immutable data structures, so each concatenation operation creates an entirely new string in memory.

2. Concatenating and formatting strings with fmt.Sprint()

Another way to concatenate strings in Go is by using the fmt.Sprint() method which is often more convenient than the + operator since it can also concatenate other non-string types.

func main() {
	s1 := "John"
	s2 := 20
	s3 := fmt.Sprint("name:", s1, ", age:",  s2)
	fmt.Println(s3)
}
output
name:John, age:20

If you need greater control over the formatting of the string, you can use the fmt.Sprintf() method which allows you to specify a template format for the resulting string:

func main() {
	s1 := "John"
	s2 := 20
	s3 := fmt.Sprintf("[name]: %s; [age]: %d", s1, s2)
	fmt.Println(s3)
}
output
[name]: John; [age]: 20

When you want to interpolate variables in a string, you must use the fmt verb that corresponds to the type of the argument. For example, %s is the verb for string, and %d is for integers. You also have a generic %v for any type and several other formatting options.

3. Concatenating a string slice with strings.Join()

If you have a string slice, you can use strings.Join() to create a single string from the splice. You need to specify a separator string in its second argument which is placed between each individual slice element in the resulting string:

func main() {
	s := []string
		"a",
		"quick",
		"brown",
		"fox",
		"jumps",
		"over",
		"the",
		"lazy",
		"dog",
	}

	fmt.Println(strings.Join(s, " ")) 
	fmt.Println(strings.Join(s, "_")) 
}
output
a quick brown fox jumps over the lazy dog
a_quick_brown_fox_jumps_over_the_lazy_dog

4. Efficient string concatenation with bytes.Buffer

The bytes.Buffer type was the go-to method for efficient string concatenation in Go up until v1.10 when strings.Builder was introduced (see the next heading below) . Its zero value is an empty byte buffer that is ready to use and it allows the manipulation of the buffer through the following methods on the buffer:

func (b *Buffer) Write(p []byte) (int, error) // implementing io.Writer
func (b *Buffer) WriteByte(c byte) error
func (b *Buffer) WriteRune(r rune) (int, error)
func (b *Buffer) WriteString(s string) (int, error)

It’s really useful when iterating over some structure and building a string incrementally perhaps after some processing. The example below reads a file and builds a string from its contents:

func main() {
	var buf bytes.Buffer

	file, err := os.Open("hello.txt")
	if err != nil {
		log.Fatal(err)
	}

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		text := strings.TrimSpace(scanner.Text())

		text = strings.ToUpper(text) + " "

		_, err := buf.WriteString(text)
		if err != nil {
			log.Fatal(err)
		}
	}

	if err := scanner.Err(); err != nil {
		log.Fatal(err)
	}

	fmt.Println(strings.TrimSpace(buf.String()))
}

Assuming the contents of hello.txt is:

hello.txt
a
quick
brown
fox
jumps
over
the
lazy
dog

You’ll get the following output:

output
A QUICK BROWN FOX JUMPS OVER THE LAZY DOG

You should also explore other useful methods on the bytes.Buffer type such as:

  • Grow(): for pre-allocating memory when the maximum size of the final result is known. Ensure to do this to derive maximum efficiency in such scenarios.
  • Reset(): For emptying the buffer while retaining the underline storage.

5. Efficient string concatenation with strings.Builder

The strings.Builder type was introduced in Go v1.10 to replace bytes.Buffer as the preferred way to efficiently concatenate strings in Go. It retains many of the methods of its predecessor so its usage is quite similar in practice:

func main() {
	var builder strings.Builder

	file, err := os.Open("hello.txt")
	if err != nil {
		log.Fatal(err)
	}

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		text := strings.TrimSpace(scanner.Text())

		text = strings.ToUpper(text) + " "

		_, err := builder.WriteString(text)
		if err != nil {
			log.Fatal(err)
		}
	}

	if err := scanner.Err(); err != nil {
		log.Fatal(err)
	}

	fmt.Println(strings.TrimSpace(builder.String()))
}
output
A QUICK BROWN FOX JUMPS OVER THE LAZY DOG

One thing to note when using strings.Builder is that copying a populated Builder will lead to a panic! The recommended way to share a Builder is to use pointers.

func main() {
	var b1 strings.Builder
	b1.WriteString("abc")
	b2 := b1
	b2.WriteString("xyz")
}
output
panic: strings: illegal use of non-zero Builder copied by value

Here’s how strings.Builder differs from bytes.Buffer:

  1. It only build strings and the strings are immutable.
  2. When you call Reset() on a Builder, the underlying storage is not retained unlike bytes.Buffer.
  3. You cant access its underlying bytes (like (*Buffer).Bytes()).
  4. It does not implement the io.Reader interface.

Benchmarking string concatenation approaches in Go

Here’s a benchmark that you can run locally to see the performance difference between the five methods described above:

main_test.go
package main

import (
	"bytes"
	"fmt"
	"strings"
	"testing"
)

var loremIpsum = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas non odio eget quam gravida laoreet vitae id est. Cras sit amet porta dui. Pellentesque at pulvinar ante. Pellentesque leo dolor, tristique a diam vel, posuere rhoncus ex. Mauris gravida, orci eu molestie pharetra, mi nibh bibendum arcu, in bibendum augue neque ac nulla. Phasellus consectetur turpis et neque tincidunt molestie. Vestibulum diam quam, sodales quis nulla eget, volutpat euismod mauris.
`

var strSLice = make([]string, LIMIT)

const LIMIT = 1000

func init() {
	for i := 0; i < LIMIT; i++ {
		strSLice[i] = loremIpsum
	}
}

func BenchmarkConcatenationOperator(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var q string
		for _, v := range strSLice {
			q = q + v
		}
	}
	b.ReportAllocs()
}

func BenchmarkFmtSprint(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var q string
		for _, v := range strSLice {
			q = fmt.Sprint(q, v)
		}
	}
	b.ReportAllocs()
}

func BenchmarkStringsJoin(b *testing.B) {
	for i := 0; i < b.N; i++ {
		q := strings.Join(strSLice, "")

		_ = q
	}
	b.ReportAllocs()
}

func BenchmarkBytesBuffer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var q bytes.Buffer

		q.Grow(len(loremIpsum) * len(strSLice))

		for _, v := range strSLice {
			q.WriteString(v)
		}
		_ = q.String()
	}
	b.ReportAllocs()
}

func BenchmarkStringBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var q strings.Builder

		q.Grow(len(loremIpsum) * len(strSLice))

		for _, v := range strSLice {
			q.WriteString(v)
		}
		_ = q.String()
	}
	b.ReportAllocs()
}

These are the results from running this benchmark on my machine with Go v1.19:

command
go test -bench=.
output
goos: linux
goarch: amd64
pkg: github.com/Freshman-tech/golang
cpu: Intel(R) Core(TM) i7-10875H CPU @ 2.30GHz
BenchmarkConcatenationOperator-16             51          24837485 ns/op        238062937 B/op      1009 allocs/op
BenchmarkFmtSprint-16                         21          53225816 ns/op        488510658 B/op      5146 allocs/op
BenchmarkStringsJoin-16                    27043             43857 ns/op          475137 B/op          1 allocs/op
BenchmarkBytesBuffer-16                    15705             76785 ns/op          950274 B/op          2 allocs/op
BenchmarkStringBuilder-16                  29877             40188 ns/op          475136 B/op          1 allocs/op
PASS
ok      github.com/Freshman-tech/golang 7.687s

According to the results above, strings.Builder was the fastest with the fewest allocations (tied with strings.Join), while bytes.Buffer came in third place. fmt.Sprint() was by far the slowest with a massive amount of allocations too due to its use of reflection while the concatenation operator was just a little bit better.

So there you have it. The strings.Builder type should be your go-to option when performing large string concatenation operations, and its closest alternatives (strings.Join() and bytes.Buffer) also have their place depending on the scenario. However, fmt.Sprint() and the + operator should be reserved for simple concatenation operations only.

Thanks for reading, and happy coding!