Mert Özen Further With Every Line
Backend Backend with Go — #15

Working with Strings in Go: strings, strconv, fmt Verbs, Bytes vs Runes and Turkish Characters

Mert Özen Nov 15, 2026 14 min 6 views
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 through ToLowerSpecial(unicode.TurkishCase, ...) and compare with ==.
  • For machine values such as codes, ISBNs, order numbers and HTTP headers, EqualFold is 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.