Working with Strings in Go: strings, strconv, fmt Verbs, Bytes vs Runes and Turkish Characters
In Go a string is an immutable sequence of UTF-8 bytes, and the strings package ignores language: strings.EqualFold does not treat "istanbul" and "İSTANBUL" as equal, and Turkish rules need unicode.TurkishCase. Normalizing and validating ISBNs, strconv error types, reading Turkish-format prices, fmt verbs and go vet, measuring strings.Builder, and RE2-based regexp.
Version note: The code in this post was built and run with Go 1.27.4; the Bookstore module's go line is 1.26.0, and the code also compiles and passes its tests with Go 1.26.11. strings.CutLast arrived in Go 1.27, so the lab that shows it is set apart with //go:build go1.27. In Go 1.27 the unicode package was upgraded to Unicode 17.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, the shipping team stores the delivery city in capitals, as "İSTANBUL". Gül from customer service types "istanbul" into the search box, and the code compares the two strings with strings.EqualFold, that is, ignoring case. Do they match? And what do you get when "ISPARTA" goes through strings.ToLower?
func main() {
tr := unicode.TurkishCase
fmt.Println(strings.ToUpper("istanbul"), strings.ToUpperSpecial(tr, "istanbul"))
fmt.Println(strings.ToLower("ISPARTA"), strings.ToLowerSpecial(tr, "ISPARTA"))
fmt.Println(strings.ToLower("İZMİR"), strings.ToLowerSpecial(tr, "İZMİR"))
fmt.Println(strings.EqualFold("Dune", "DUNE"))
fmt.Println(strings.EqualFold("istanbul", "İSTANBUL"), strings.EqualFold("ısparta", "ISPARTA"))
}
$ go run ./labs/15-turkish
ISTANBUL İSTANBUL
isparta ısparta
izmir izmir
true
false false
They do not match. EqualFold finds "Dune" and "DUNE" equal, but not "istanbul" and "İSTANBUL", and ToLower turns "ISPARTA" into "isparta". Go's strings functions do not look at a language: they use the mapping Unicode defines for everyone, and in that mapping the lower case of I is i and the upper case of i is I. In C#, ToUpper() looks at CurrentCulture by default; Go has no such "current culture". When you need Turkish rules, you say so explicitly with unicode.TurkishCase. This post covers everyday string work in Go: bytes versus runes, strings, strconv, fmt verbs, the cost of concatenation and, briefly, regexp. On the Bookstore side we write a function that normalizes and validates an ISBN, and a lab that reads a price list in Turkish number format.
string, byte and rune: A Short Recap
The basic rule from post 03: in Go a string is an immutable sequence of bytes, and source code and string literals are written in UTF-8, which encodes a character in 1 to 4 bytes. len counts bytes, not letters; s[i] returns a byte; for range walks the string rune by rune. In C#, a string is made of UTF-16 units and s.Length counts UTF-16 units; in Go the unit is the byte. The most common practical consequence of this difference is truncation:
// Lab 15: bytes, runes and what len really counts.
package main
import (
"fmt"
"unicode/utf8"
)
// firstN returns at most n characters of s without cutting a rune in half.
func firstN(s string, n int) string {
i := 0
for pos := range s { // pos is the byte index where each rune starts
if i == n {
return s[:pos]
}
i++
}
return s
}
func main() {
title := "Çalıkuşu"
fmt.Println(len(title), utf8.RuneCountInString(title))
fmt.Printf("%q %q\n", title[:5], firstN(title, 5))
for pos, r := range "Dün" {
fmt.Printf("%d:%c(%U) ", pos, r, r)
}
fmt.Println()
b := []byte("Dune")
b[0] = 'J' // a []byte can change; the string it came from cannot
fmt.Println(string(b))
broken := "Emma\xff" // invalid UTF-8 from a badly encoded file
fmt.Println(utf8.ValidString(broken), []rune(broken)[4] == utf8.RuneError)
}
$ go run ./labs/15-runes
11 8
"Çal\xc4" "Çalık"
0:D(U+0044) 1:ü(U+00FC) 3:n(U+006E)
June
false true
title[:5] took five bytes and cut one of the two bytes of the letter "ı" in half, which is why %q printed \xc4 at the end. Write such a string to JSON and you send a broken character. firstN uses the byte indexes that range hands out and cuts where the fifth rune starts; converting with []rune(s) does the same job but copies the whole string. The last line shows what invalid UTF-8 looks like: "\xff" is not a character, and range and []rune report it as utf8.RuneError (U+FFFD). If a file from outside has a broken encoding, utf8.ValidString tells you up front. In post 10 we saw json/v2 reject invalid UTF-8; v1 silently turns it into U+FFFD.
The strings Package: Cut, Map, Fields and the ISBN
Users type ISBNs in every shape: "978-0-441-17271-9", "ISBN 978 0 441 17271 9", "isbn: 9780441172719". Bookstore's catalog has a single shape: 13 digits without hyphens. The new function in internal/catalog normalizes the input and checks that the last digit, the check digit, is right:
// NormalizeISBN turns "ISBN 978-0-441-17271-9" into "9780441172719" and
// checks the check digit. Hyphens and spaces are dropped; nothing else is.
func NormalizeISBN(s string) (string, error) {
s = strings.TrimSpace(s)
if len(s) >= 4 && strings.EqualFold(s[:4], "isbn") {
s = strings.TrimLeft(s[4:], " :-")
s = strings.TrimLeft(strings.TrimPrefix(s, "13"), " :") // "ISBN-13: ..."
}
isbn := strings.Map(func(r rune) rune {
if r == '-' || r == ' ' {
return -1 // drop the rune
}
return r
}, s)
if len(isbn) != 13 || !digits(isbn) {
return "", fmt.Errorf("%q: %w", s, ErrInvalidISBN)
}
if !strings.HasPrefix(isbn, "978") && !strings.HasPrefix(isbn, "979") {
return "", fmt.Errorf("%q: prefix must be 978 or 979: %w", s, ErrInvalidISBN)
}
if want := checkDigit(isbn[:12]); isbn[12] != want {
return "", fmt.Errorf("%q: check digit is %c, want %c: %w", s, isbn[12], want, ErrInvalidISBN)
}
return isbn, nil
}
// checkDigit computes the 13th digit: weights 1 and 3 alternate.
func checkDigit(first12 string) byte {
sum := 0
for i := range len(first12) {
d := int(first12[i] - '0') // a byte, not a rune: digits are ASCII
if i%2 == 1 {
d *= 3
}
sum += d
}
return byte('0' + (10-sum%10)%10)
}
Most of the functions we used have a C# counterpart: TrimSpace → Trim(), HasPrefix → StartsWith, EqualFold → string.Equals(a, b, StringComparison.OrdinalIgnoreCase). strings.Map calls a function for every rune and drops the rune when the function returns a negative value; it is the shortest way to delete hyphens and spaces. Reading bytes with first12[i] inside checkDigit is a deliberate choice: one line earlier, digits made sure the input consists only of ASCII digits. With unicode.IsDigit, Arabic digits would also pass; the "٩٧٨..." row in the test is there for that. GET /api/books/{isbn} now accepts both shapes:
$ curl -i http://127.0.0.1:52715/api/books/978-0-441-17271-9
HTTP/1.1 200 OK
Content-Type: application/json
{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","price":34990,"stock":12}
$ curl -i http://127.0.0.1:52715/api/books/9780441172718
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
"9780441172718": check digit is 8, want 9: invalid ISBN-13
Before, this request returned 404; now the client can tell a mistyped number from a book that does not exist. I added two rows to the handler test from post 12: 200 for the hyphenated ISBN, and 400 for 9780000000000, whose check digit is wrong. The 404 row still holds but moved to 9780000000002, which is not in the catalog. We used strings.Cut in ParseMoney in post 11; Go 1.27 adds CutLast. Both return three values and replace the Index-plus-slicing pair:
func main() {
name := "orders.2026-11-14.csv"
before, after, found := strings.Cut(name, ".")
fmt.Printf("Cut: %q %q %v\n", before, after, found)
base, ext, found := strings.CutLast(name, ".")
fmt.Printf("CutLast: %q %q %v\n", base, ext, found)
}
$ go run ./labs/15-cutlast
Cut: "orders" "2026-11-14.csv" true
CutLast: "orders.2026-11-14" "csv" true
For splitting, two more functions are enough: strings.Split splits on a separator and returns a one-element slice ([""]) even for the empty string; strings.Fields treats runs of whitespace as one separator and returns an empty slice for the empty string. Since Go 1.24, SplitSeq, FieldsSeq and Lines do the same work as iterators, without building a slice.
Turkish Characters: TurkishCase and EqualFold
Back to the output from Guess First. strings.ToUpperSpecial(unicode.TurkishCase, s) and ToLowerSpecial apply the i/ı rules of Turkish and Azerbaijani. An interesting detail: in Go, strings.ToLower("İZMİR") gives "izmir", because Unicode's simple mapping turns İ straight into i. The problem is the other direction: I always becomes i, and i always becomes I. When you work with Turkish names and cities, two rules are enough:
- Convert values you display or search for in Turkish text with
unicode.TurkishCase; for a case-insensitive Turkish comparison, pass both sides throughToLowerSpecial(unicode.TurkishCase, ...)and compare with==. - For machine values such as codes, ISBNs, order numbers and HTTP headers,
EqualFoldis the right tool; you do not want Turkish rules there.
EqualFold uses Unicode's "simple case folding" table, and in that table İ and i, ı and I do not fold into each other; that is where the two false values come from. For broader needs (language-aware upper case, sorting in Turkish alphabetical order) there are the cases and collate packages in the golang.org/x/text module; we come back to sorting in post 17.
strconv: Converting to Numbers and Its Error Types
The Go counterpart of C#'s int.Parse/int.TryParse pair is a single function: strconv.Atoi returns the value and an error. The error is a *strconv.NumError, and you can tell the reason inside it apart with errors.Is:
for _, in := range []string{"42", "12a", "99999999999999999999"} {
n, err := strconv.Atoi(in)
fmt.Println(n, err, errors.Is(err, strconv.ErrSyntax), errors.Is(err, strconv.ErrRange))
}
f, err := strconv.ParseFloat("349,90", 64)
fmt.Println(f, err)
fmt.Println(strconv.Quote("Dune\t\"Arrakis\""), strconv.FormatInt(34990, 10))
$ go run ./labs/15-strconv
42 <nil> false false
0 strconv.Atoi: parsing "12a": invalid syntax true false
9223372036854775807 strconv.Atoi: parsing "99999999999999999999": value out of range false true
0 strconv.ParseFloat: parsing "349,90": invalid syntax
"Dune\t\"Arrakis\"" 34990
...
Two details deserve attention. When the value is out of range, Atoi returns not 0 but the largest value the type can hold; code that does not check the error carries on with an order for 9,223,372,036,854,775,807 copies. And ParseFloat takes no CultureInfo as in C#: the decimal separator is always a dot. We have not carried prices as float64 since post 10, but in a file from a Turkish Excel sheet the comma is unavoidable. A few lines of the CSV file the supplier sends:
// priceList is what a supplier exports from a Turkish Excel sheet.
const priceList = `isbn;title;price
// parseTRPrice reads "1.249,50 TL": dot for thousands, comma for decimals.
func parseTRPrice(s string) (catalog.Money, error) {
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "TL"))
s = strings.ReplaceAll(s, ".", "")
s = strings.Replace(s, ",", ".", 1)
return catalog.ParseMoney(s)
}
...
line 2: 9780441172719 "Dune" 349.90 TRY
line 3: 9780547928227 "The Hobbit" 1249.50 TRY
line 4: 9780141439549 "Middlemarch" 310.00 TRY
line 5: "abc": invalid money amount
parseTRPrice first drops the " TL" suffix and the dots used as thousands separators, turns the comma into a dot, and hands the result to catalog.ParseMoney without ever going through float64. Splitting lines with strings.Split is enough for this small example, but it breaks the moment a quoted field contains a semicolon; for real CSV we use encoding/csv in post 18. In the other direction there are strconv.FormatInt, Quote and AppendInt, which writes into a slice; Quote is useful when you log a value that contains control characters.
fmt Verbs and Stringer
In C# you use string interpolation ($"{book.Title} has {book.Stock}"); Go has no interpolation, it has fmt.Sprintf and verbs. The most used ones:
func main() {
b := catalog.Book{ISBN: "9780441172719", Title: "Dune", Author: "Frank Herbert", Price: 34990, Stock: 12}
fmt.Printf("%v\n", b)
fmt.Printf("%+v\n", b)
fmt.Printf("%#v\n", b)
fmt.Printf("%T %T\n", b, b.Price)
// Money has a String method: %v and %s use it, %d does not.
fmt.Printf("%v | %s | %d\n", b.Price, b.Price, b.Price)
fmt.Printf("%q %x % x\n", b.Title, b.Title, "Dü")
fmt.Printf("[%-12s][%15s][%6.2f][%05d]\n", b.Title, b.Author, 349.9, 42)
fmt.Printf("%v %d\n", orders.StatusPaid, orders.StatusPaid)
err := fmt.Errorf("place order: %w", orders.ErrOutOfStock)
fmt.Println(err, errors.Is(err, orders.ErrOutOfStock))
s := fmt.Sprintf("%s has %d in stock", b.Title, b.Stock)
fmt.Println(s)
}
$ go run ./labs/15-fmt
{9780441172719 Dune Frank Herbert 349.90 TRY 12}
{ISBN:9780441172719 Title:Dune Author:Frank Herbert Price:349.90 TRY Stock:12}
catalog.Book{ISBN:"9780441172719", Title:"Dune", Author:"Frank Herbert", Price:34990, Stock:12}
catalog.Book catalog.Money
349.90 TRY | 349.90 TRY | 34990
"Dune" 44756e65 44 c3 bc
[Dune ][ Frank Herbert][349.90][00042]
Paid 1
place order: out of stock true
Dune has 12 in stock
%v is the default format, %+v adds field names to a struct, and %#v prints the value as Go code; that last one is the most useful for debugging. Types that satisfy the Stringer interface from post 04 (Money, Status) are printed with their own String methods under %v and %s; %#v and %d show the raw value. %q prints quoted and escaped, % x prints bytes as hex with spaces between them, and here too you see that "ü" is two bytes (c3 bc). Width is a minimum: %-12s left-aligns and pads to 12. We saw %w in post 05; it only means something in fmt.Errorf. When a verb and its argument do not match, the program compiles and runs, and writes the error into the output:
package main
import "fmt"
func main() {
title, stock := "Dune", 12
fmt.Printf("%d has %d in stock\n", title, stock) // %d for a string
fmt.Printf("%s has %d in stock\n", title) // one argument missing
}
$ go run .
%!d(string=Dune) has 12 in stock
Dune has %!d(MISSING) in stock
$ go vet .
main.go:7:14: fmt.Printf format %d has arg title of wrong type string
main.go:8:21: fmt.Printf format %d reads arg #2, but call has 1 arg
The printf check in go vet catches these mistakes before the build ships, and go test runs that check by itself. It is the closest thing to C#'s compiler-checked interpolation.
strings.Builder and Concatenating with +
Because Go strings are immutable, s += t allocates a new string every time and copies everything so far. The counterpart of C#'s StringBuilder is strings.Builder. I measured four ways to build a list from 1,000 titles:
func BenchmarkPlus(b *testing.B) {
for b.Loop() {
s := ""
for _, t := range titles {
s += t + "\n" // a new string, and a copy of everything so far, each time
}
_ = s
}
}
func BenchmarkBuilderGrow(b *testing.B) {
for b.Loop() {
var sb strings.Builder
sb.Grow(len(titles) * (len("Middlemarch") + 1)) // one allocation
for _, t := range titles {
sb.WriteString(t)
sb.WriteByte('\n')
}
_ = sb.String()
}
}
$ go test -run '^$' -bench . -benchmem -cpu 1 -count 3 ./labs/15-builder
BenchmarkPlus 2852 450433 ns/op 6366120 B/op 1000 allocs/op
BenchmarkPlus 2343 692214 ns/op 6366120 B/op 1000 allocs/op
BenchmarkPlus 1561 664994 ns/op 6366120 B/op 1000 allocs/op
BenchmarkBuilder 95614 12677 ns/op 46576 B/op 15 allocs/op
BenchmarkBuilder 123186 10284 ns/op 46576 B/op 15 allocs/op
BenchmarkBuilder 168128 7274 ns/op 46576 B/op 15 allocs/op
BenchmarkBuilderGrow 240349 5004 ns/op 12288 B/op 1 allocs/op
BenchmarkBuilderGrow 242506 5104 ns/op 12288 B/op 1 allocs/op
BenchmarkBuilderGrow 229485 5384 ns/op 12288 B/op 1 allocs/op
BenchmarkJoin 190495 6785 ns/op 12288 B/op 1 allocs/op
BenchmarkJoin 172795 6574 ns/op 12288 B/op 1 allocs/op
BenchmarkJoin 171823 6487 ns/op 12288 B/op 1 allocs/op
The timings were taken on a machine that was also running other work and they fluctuate, but the allocation columns are clear: + allocates 6.3 MB per call, Builder 46 KB, and a Builder that reserves space up front with Grow, like strings.Join, makes a single 12 KB allocation. For joining two or three pieces, + is perfectly fine; the compiler already concatenates the operands of a single expression in one go. The problem is growing a string in a loop. Builder has one rule: writing to a copy of a Builder that is already in use panics, so pass it to functions as a pointer.
regexp: RE2 in Brief
The regexp package uses the syntax of RE2, Google's regex engine whose matching time stays proportional to the length of the input. In exchange, some features of the .NET regex engine are missing:
func main() {
fmt.Println(isbnLike.MatchString("9780441172719"), isbnLike.MatchString("9780441172718"))
price := regexp.MustCompile(`(\d+),(\d{2}) TL`)
fmt.Println(price.FindStringSubmatch("Dune 349,90 TL"))
//lint:ignore SA1000 the lab shows the error on purpose
_, err := regexp.Compile(`(?<=ORD-)\d+`) // lookbehind, fine in .NET
fmt.Println(err)
}
$ go run ./labs/15-regexp
true true
[349,90 TL 349 90]
error parsing regexp: invalid named capture: `(?<=ORD-)\d+`
There is no lookbehind, lookahead or backreference; because Go reads (?<name>...) as a named group, the error message even says "invalid named capture". In return there is no risk of a badly written pattern locking up the CPU on user input; in .NET that takes RegexOptions.NonBacktracking or a timeout. When you delete the //lint:ignore line in the lab, staticcheck catches the invalid pattern before the program runs: error parsing regexp: invalid named capture: `(?<=ORD-)\d+` (SA1000). The second true on the first line matters too: the regex only checks the shape and accepts an ISBN with a wrong check digit. For simple shape checks, strings functions are both faster and easier to read; leave regexes to places that really need a pattern, and compile them once at package level with MustCompile.
Production Notes
Normalize text from outside at the boundary. Once the handler passes the ISBN through NormalizeISBN, the catalog, the database and the logs all see a single shape; do the same for customer e-mail addresses (TrimSpace + ToLower; you do not want Turkish rules for e-mail) and coupon codes. Decide whether you limit lengths and truncate in bytes or in characters: nvarchar(200) in the database counts characters, len counts bytes.
Use %q when you put user input into an error message. The errors from NormalizeISBN print the input quoted and escaped, so a value carrying a newline cannot fake a log line. slog's handlers already do this, but it matters for logs written with log.Printf or fmt.
On a hot path, consider strconv instead of fmt.Sprintf. Sprintf converts its arguments to interfaces and formats them with reflection; somewhere that runs thousands of times a second, strconv.AppendInt or a Builder can make a difference. Do not change it without measuring: the pprof profile from post 13 shows which line matters.
Common Pitfalls
Taking s[i] for a character. s[i] is a byte; cutting Turkish text with s[:n] can split a letter in half. Use range or the utf8 package.
Using strings.ToUpper/ToLower/EqualFold on Turkish text. These functions are language-independent; Turkish needs unicode.TurkishCase.
Ignoring the strconv error. n, _ := strconv.Atoi(s) silently accepts 0 for broken input and the largest value for out-of-range input.
Expecting ParseFloat to read Turkish numbers. It does not accept a decimal comma; do the conversion yourself, and never touch float64 for money.
Growing a string with += in a loop. In small lists you will not notice; when you produce a large report or CSV, the allocations keep the GC busy.
A Small Experiment
★☆☆ Add a function sameTR(a, b string) bool to labs/15-turkish that compares two strings case-insensitively with Turkish rules. It must return true for "istanbul" and "İSTANBUL", and for "ısparta" and "ISPARTA".
★★☆ Extend NormalizeISBN to accept ISBN-10: turn a number like "0-441-17271-7" into an ISBN-13 with the 978 prefix (the ISBN-10 check digit is computed modulo 11 and can be "X"). Add at least four rows to the table test.
★★★ Move parseTRPrice into the catalog package and write a table test for the inputs "1.249,50", "1249,5", "0,05", "1.2.3,00" and "12,345". Decide which of them must fail; then write the Turkish-format counterpart of Money.Decimal and check the round trip with a fuzz test, as in post 12.
We are not done with strings, but the next topic is the formatting rule that surprises C# developers most in Go: 2006-01-02 instead of yyyy-MM-dd. The next post covers the time package, time zones and the monotonic clock, and looks at the three-hour shift bug from post 11 from the time zone side.