How to Format Strings in Go with fmt.Sprintf

Use fmt.Sprintf with format verbs like %s and %d to build custom strings from variables in Go.

The string construction problem

You need to build a log message. The message contains a username, an ID, a timestamp, and a status code. Concatenation with the + operator turns the code into a unreadable chain of quotes and variables. The result looks like a puzzle where the pieces are glued together in the wrong order.

Go provides the fmt package to solve this. The package treats strings as templates. You write the template with placeholders. You pass the values as arguments. The function fills the placeholders and returns the result. This pattern separates the structure of the message from the data. The code stays readable. The message stays correct.

The function fmt.Sprintf is the workhorse for this job. The name stands for "string print formatted." It constructs a string based on a format template and returns the result. It does not print to the screen. It hands the string back to your code so you can store it, return it, or pass it to another function.

Format verbs and the template engine

The format string contains plain text and special verbs. A verb starts with a percent sign % followed by a letter. The letter tells Sprintf what kind of value to expect and how to format it.

The most common verbs are %s for strings, %d for integers, and %v for any value. The %v verb is the default. It prints the value in a format appropriate for its type. If the value implements the fmt.Stringer interface, %v calls the String() method. This convention allows types to control their own representation.

Here is the simplest usage. The code builds a greeting message using a name and an age.

package main

import (
	"fmt"
)

func main() {
	name := "Alice"
	age := 30
	// %s expects a string. %d expects an integer.
	// Sprintf returns the formatted string without printing it.
	msg := fmt.Sprintf("Hello, %s! You are %d years old.", name, age)
	fmt.Println(msg)
}

The compiler checks the number of arguments against the number of verbs at compile time only if the format string is a constant. If you pass a variable as the format string, the compiler cannot verify the count. The check happens at runtime. This distinction matters for safety. Always prefer constant format strings so the compiler can catch mismatches early.

Width, precision, and flags

Verbs support modifiers to control output width, precision, and alignment. These modifiers sit between the percent sign and the verb letter.

Width sets the minimum number of characters. If the value is shorter than the width, Sprintf pads the output with spaces by default. You can change the padding character to zero by adding 0 before the width. Precision controls detail. For floats, precision sets the number of digits after the decimal point. For strings, precision truncates the output to that many characters.

Flags modify behavior. The - flag left-aligns the output. The + flag forces a sign for numbers. The # flag adds extra information, like a 0x prefix for hex or field names for structs.

Here is how modifiers change the output. The code demonstrates padding, truncation, and float precision.

package main

import (
	"fmt"
)

func main() {
	// %10s pads the string to 10 characters with spaces on the left.
	// %-10s left-aligns the string within 10 characters.
	// %.5s truncates the string to 5 characters.
	fmt.Println(fmt.Sprintf("|%10s|", "Hi"))
	fmt.Println(fmt.Sprintf("|%-10s|", "Hi"))
	fmt.Println(fmt.Sprintf("|%.5s|", "Hello World"))

	// %05d pads the integer to 5 digits with zeros.
	// %.2f formats the float to 2 decimal places.
	fmt.Println(fmt.Sprintf("|%05d|", 42))
	fmt.Println(fmt.Sprintf("|%.2f|", 3.14159))
}

The # flag is useful for debugging. When used with %v on a struct, %#v prints the Go syntax representation including field names. This output is valid Go code that could reconstruct the value. The + flag with %v on a struct prints field names alongside values but without the syntax sugar. Use %+v when you need to identify which field holds which value in a log message.

What happens under the hood

Sprintf takes a format string and a variadic list of arguments. The signature is func Sprintf(format string, a ...any) string. The ...any part means the function accepts any number of arguments of any type. The any type is an alias for interface{}. This allows Sprintf to accept integers, strings, structs, pointers, and custom types in the same call.

The function parses the format string character by character. When it hits a verb, it grabs the next argument from the list. It performs a type assertion to determine the concrete type of the argument. Based on the verb and the type, it selects a formatting routine. The routine converts the value to bytes and appends them to an internal buffer. After processing all verbs, the function converts the buffer to a string and returns it.

This mechanism explains why Sprintf is flexible but has a cost. The any type erases the concrete type information. The function must check the type at runtime for every argument. The internal buffer grows as needed, which may trigger memory allocations. The final string is a new allocation.

If you pass too few arguments, Sprintf uses zero values for the missing ones. If you pass too many, the extra arguments are ignored. The function never panics on argument count mismatches. It degrades gracefully. This design choice prevents runtime crashes in production code. The trade-off is that bugs in format strings might go unnoticed if you don't check the output.

Pitfalls and silent failures

The most common mistake is a mismatch between verbs and arguments. If you write %s %d but pass two strings, the output contains error markers. The markers start with ! and describe the problem. For example, %!d(string=hello) indicates that a string was passed where an integer was expected.

The compiler rejects the program with a type mismatch error only if the format string is a constant and the argument types are incompatible with the verbs in a way the compiler can detect. For dynamic format strings, the check happens at runtime. The runtime produces the ! markers. These markers are easy to miss in logs. Always review format strings carefully.

Another pitfall is using %s on a type that is not a string. %s requires a string or a byte slice. If you pass an integer, the output shows %!s(int=42). Use %v when you are unsure of the type. %v handles almost everything. It calls String() if available, prints numbers as digits, prints booleans as true/false, and prints structs with braces.

Pointers require attention. %v on a pointer prints the value the pointer points to. %p prints the memory address in hexadecimal. If you want the address, use %p. If you want the value, use %v. Dereferencing happens automatically for %v. This behavior simplifies code but can be confusing if you expect the address.

Performance and allocation

Sprintf allocates memory for the result string. It also allocates for the internal buffer during formatting. In a tight loop, repeated calls to Sprintf create garbage that the garbage collector must clean up. This overhead can hurt performance in high-throughput systems.

If you need to build a large string from many parts, use strings.Builder. The builder maintains a reusable buffer. You write to the builder with WriteString, WriteByte, or WriteRune. When you are done, call String() to get the result. This approach minimizes allocations.

Here is how to use strings.Builder for efficient construction. The code builds a CSV line without creating intermediate strings.

package main

import (
	"fmt"
	"strings"
)

func main() {
	// Builder reuses its internal buffer across writes.
	var buf strings.Builder
	// WriteString appends to the buffer without allocating a new string.
	buf.WriteString("Alice,")
	buf.WriteString("30,")
	buf.WriteString("active")
	// String() returns the final result as a single allocation.
	result := buf.String()
	fmt.Println(result)
}

Use Sprintf for readability when performance is not critical. Use strings.Builder when you are concatenating many parts or running in a hot loop. The compiler cannot optimize Sprintf away because it involves runtime type checks and dynamic formatting. strings.Builder operations are simple memory copies that the compiler can optimize more aggressively.

Decision matrix

Choose the right tool based on the destination and the performance requirements. The fmt package provides variants for different outputs. Sprintf returns a string. Printf writes to standard output. Fprintf writes to an io.Writer. Errorf returns an error value.

Use fmt.Sprintf when you need to construct a string for storage, return, or further processing. Use fmt.Printf when debugging and you want immediate output to the terminal. Use fmt.Fprintf when writing to a file, a network connection, or an HTTP response. Use fmt.Errorf when you need to return an error with context. Use strings.Builder when concatenating many parts in a loop to minimize allocations. Use simple concatenation with + when joining two static strings or a static string with a variable, as the compiler often optimizes this pattern.

The fmt package is always imported in Go projects. The community accepts the verbosity of format strings because they make the structure of the output explicit. Error handling follows the standard pattern. Fprintf returns an error. Check it with if err != nil { return err }. Sprintf does not return an error. It always succeeds in producing a string, even if the output contains ! markers. Trust the format string. Verify the output in tests.

Where to go next