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

Go Fundamentals: Types, Zero Values, Slices, Maps, Pointers and Control Flow

Mert Özen Nov 15, 2026 16 min 3 views
Go Fundamentals: Types, Zero Values, Slices, Maps, Pointers and Control Flow

In Go a slice is a small header made of a pointer, len and cap, and sub-slices share the same backing array, which is why a filter working on books[:0] corrupts the original catalog. The zero value philosophy, constants with iota, strings vs bytes vs runes and the len trap with Turkish characters, append growing capacity, nil maps and random map order, pointers and Go 1.26's new(expr), the 1.22 loop variable and nil slices turning into null in JSON.

Version note: The code in this post was built and run with Go 1.27.4; the module's go line is 1.26.0 and the code also compiles with Go 1.26.11. new taking a value, as in new(15), arrived in Go 1.26.

Guess First

In this post we add a price filter to the catalog of Bookstore, the online bookstore used as the shared example across this site's series. Someone new to Go slices often writes the filter like this to avoid allocating: the result is collected on top of the input itself. The code compiles and go vet says nothing:

// Lab 03: an in-place filter that quietly overwrites its input.
package main

import "fmt"

type Book struct {
    Title string
    Price int64
}

func cheaperInPlace(books []Book, limit int64) []Book {
    result := books[:0] // reuses the input's backing array
    for _, b := range books {
        if b.Price <= limit {
            result = append(result, b)
        }
    }
    return result
}

func main() {
    books := []Book{{"Dune", 34990}, {"The Hobbit", 28950}, {"Middlemarch", 31000}, {"Emma", 19900}}
    cheap := cheaperInPlace(books, 30000)
    fmt.Println(cheap)
    fmt.Println(books)
}

Prices are in kuruş; 30000 means 300 TRY. The first line will show the two books under 300 TRY, that much is clear. The question is the second line: what does books print after the filter? In C#, when you pass a List<Book> to a method that builds a new list, the original list does not change. Is it the same here?

go run ./labs/03-filter
[{The Hobbit 28950} {Emma 19900}]
[{The Hobbit 28950} {Emma 19900} {Middlemarch 31000} {Emma 19900}]

The original catalog is broken: Dune is gone and Emma appears twice. books[:0] is not a new list but a zero-length slice looking at the same block of memory. As append wrote into that block, it overwrote the first elements of the original. To see why, we first look at Go's types and the idea of zero values, then open up the slice. At the end of the post we add the correct version of the filter to the Bookstore API.

Variables, Constants and Zero Values

There are two ways to declare a variable in Go. var count int states the type explicitly and gives the variable its zero value. price := 349.90 infers the type from the value on the right; it is the counterpart of C#'s var, but it can only be used inside functions. I put the small experiments of this post into the labs/ folder of the module; each one is a separate program with its own main package:

// Lab 03: variables, constants, zero values, strings and runes.
package main

import (
    "fmt"
    "unicode/utf8"
)

type OrderStatus int

const (
    Pending OrderStatus = iota
    Paid
    Shipped
    Cancelled
)

type Book struct {
    ISBN     string
    Title    string
    Price    int64
    InStock  bool
    Tags     []string
    Discount *int
}

func main() {
    var count int
    var title string
    var b Book
    fmt.Printf("count=%d title=%q\n", count, title)
    fmt.Printf("%+v\n", b)
    fmt.Println(b.Tags == nil, len(b.Tags), b.Discount == nil)

    price := 349.90 // := infers float64
    isbn := "9780441172719"
    fmt.Printf("%T %T %v %v\n", price, isbn, Paid, Cancelled)

    turkish := "Çalıkuşu"
    fmt.Println(len(turkish), utf8.RuneCountInString(turkish))
    fmt.Println(turkish[0], string(turkish[0]))
    for i, r := range turkish[:4] {
        fmt.Printf("%d:%c ", i, r)
    }
    fmt.Println()

    b.Discount = new(15) // Go 1.26: new with an expression
    fmt.Println(*b.Discount)
}
go run ./labs/03-basics
count=0 title=""
{ISBN: Title: Price:0 InStock:false Tags:[] Discount:<nil>}
true 0 true
float64 string 1 3
11 8
195 Ã
0:Ç 2:a 3:l
15

Go has no variable without an initial value. Every type has a zero value: 0 for numbers, "" for strings, false for bool, and nil for pointers, slices, maps, channels, functions and interfaces. A struct's zero value is the zero value of all its fields. The line var b Book gave us a usable book without calling a constructor. Instead of fighting nullable reference type warnings as in C#, in Go you design types so that their zero values are meaningful: that is why the empty catalog.Store struct from 02 is ready to use with var store catalog.Store. The standard library follows the same approach: sync.Mutex and bytes.Buffer work with their zero values.

iota in the const block is a counter that increases by one on each line of the block. Pending became 0, Paid 1, Cancelled 3. Go has no enum keyword; declaring a named type with type OrderStatus int and defining constants of that type is the counterpart of C#'s enum OrderStatus. Go does not prevent an undefined value such as OrderStatus(42) (C# enums do not prevent it either). In C#, a switch expression warns about missing cases with CS8509; the Go compiler has no counterpart. That is why we will guard state transitions with methods in 04.

Type conversions in Go are always explicit. int and int64 are different types and do not convert into each other on their own; you have to write int64(count). There is no implicit conversion like C#'s int to long. This is a little tiring in the first weeks, but in money calculations it prevents a number of the wrong type from slipping in quietly.

string, byte and rune: The len Trap with Turkish Characters

The 11 8 line in the output says something everyone working with Turkish text needs to know. In Go, a string is an immutable sequence of bytes, and source code is UTF-8. "Çalıkuşu" has 8 letters, but because Ç, ı and ş take two bytes each in UTF-8, len returned 11. To count letters, use utf8.RuneCountInString. rune is another name for int32, holding one Unicode code point; it resembles C#'s char, but in C# a char is a UTF-16 unit, while in Go a rune is a code point.

turkish[0] returned a byte, not a letter: 195, the first byte of Ç. When string(turkish[0]) interpreted that byte on its own as a character, "Ã" came out. range over a string, on the other hand, walks rune by rune and gives the byte index where each rune starts: 0, 2, 3. Since Ç is two bytes, 1 was skipped. In the C# Fundamentals series post "Strings in C#" we covered the Turkish I problem; in Go, the difference between bytes and letters comes on top of it. We will return to string handling in detail in a separate post later.

Arrays and Slices: len, cap and Shared Backing Arrays

Go has two separate sequence types. An array's length is part of its type: [4]string and [5]string are different types. An array is a value type; it is copied when you assign it or pass it to a function. In everyday code you almost never use arrays; you use slices. A slice is a small three-part structure: a pointer to an array, a length (len) and a capacity (cap). This structure is called the slice header, and the array it points to is the backing array.

// Lab 03: arrays, slices, len/cap, append and shared backing arrays.
package main

import (
    "fmt"
    "slices"
)

func main() {
    shelf := [4]string{"Dune", "Emma", "The Hobbit", "Middlemarch"}
    copied := shelf
    copied[0] = "Changed"
    fmt.Println(shelf[0], copied[0])

    all := shelf[:] // slice over the whole array
    firstTwo := all[:2]
    fmt.Println(len(firstTwo), cap(firstTwo))

    firstTwo[1] = "Persuasion"
    fmt.Println(shelf[1])

    firstTwo = append(firstTwo, "Ulysses")
    fmt.Println(shelf[2], len(firstTwo), cap(firstTwo))

    firstTwo = append(firstTwo, "Beloved", "Dracula")
    firstTwo[0] = "Frankenstein"
    fmt.Println(shelf[0], len(firstTwo), cap(firstTwo))

    var titles []string
    for i := range 10 {
        titles = append(titles, fmt.Sprint(i))
        fmt.Print(cap(titles), " ")
    }
    fmt.Println()

    prices := []int{34990, 28950, 31000, 19900}
    sorted := slices.Clone(prices)
    slices.Sort(sorted)
    fmt.Println(prices, sorted, slices.Contains(prices, 19900), slices.Index(prices, 31000))
}
Dune Changed
2 4
Persuasion
Ulysses 3 4
Dune 5 8
2 2 4 4 8 8 8 8 16 16
[34990 28950 31000 19900] [19900 28950 31000 34990] true 2

03-slice-header-en.png

Let's go line by line. copied := shelf copied the array, so shelf[0] did not change. firstTwo := all[:2] opened no new array: it is a slice of length 2 and capacity 4 that looks at shelf. Writing to firstTwo[1] changed shelf[1]. On the first append there was room in the capacity; instead of opening a new array, Go wrote the element over shelf[2], and "The Hobbit" became "Ulysses". The second append would raise the length to 5 while the capacity was 4. This time the runtime opened a new array with capacity 8, copied the old elements and pointed firstTwo at it. That is why writing to firstTwo[0] no longer affected shelf[0].

This is also the story of the filter in Guess First. books[:0] is a slice with capacity 4 that looks at the same backing array as books. Since append never needed to make room, it wrote the matching books in turn over elements 0 and 1. The original slice's header did not change, so its length is still 4 and it holds both the newly written and the old elements.

The capacity line (2 2 4 4 8 8 8 8 16 16) shows that append grows the capacity by doubling. The details of the growth formula are internal to the runtime and may change between releases; look at the pattern, not the numbers. C#'s List<T> also keeps an internal array that grows the same way. The difference: List<T> is a reference type and shares its internal array with nobody, while in Go a slice is copied as a value but the copies see the same backing array. The capacity discussion in the C# Collections series post "A Guide to C# List<T>" applies here too; the sharing part is specific to Go.

The slices package on the last line has been in the standard library since Go 1.21. slices.Clone gives a copy with a new backing array, slices.Sort sorts in place, Contains and Index do a linear search. If you are used to LINQ, these functions will be the first things you look for; there is no chaining, each takes a slice and returns a result.

Maps: nil Maps, Random Order and comma-ok

A map is the counterpart of C#'s Dictionary<TKey, TValue>. Its behaviour differs in three places. Let's try them with stock counts:

// Lab 03: maps, iteration order, pointers, switch and defer.
package main

import (
    "fmt"
    "maps"
    "slices"
)

type Book struct {
    Title string
    Stock int
}

func restock(b *Book, n int) {
    b.Stock += n
}

func restockCopy(b Book, n int) {
    b.Stock += n
}

func label(stock int) string {
    switch {
    case stock == 0:
        return "out of stock"
    case stock < 5:
        return "low"
    default:
        return "ok"
    }
}

func main() {
    defer fmt.Println("main finished")

    stock := map[string]int{"Dune": 12, "Emma": 7, "The Hobbit": 0, "Middlemarch": 4}
    for range 3 {
        for title := range stock {
            fmt.Print(title, " | ")
        }
        fmt.Println()
    }
    for _, title := range slices.Sorted(maps.Keys(stock)) {
        fmt.Printf("%s=%s ", title, label(stock[title]))
    }
    fmt.Println()

    n, ok := stock["Ulysses"]
    fmt.Println(n, ok)

    b := Book{Title: "Emma", Stock: 7}
    restockCopy(b, 5)
    fmt.Println(b.Stock)
    restock(&b, 5)
    fmt.Println(b.Stock)

    var reserved map[string]int
    fmt.Println(reserved["Dune"], len(reserved))
    //lint:ignore SA5000 the panic is the point of this lab
    reserved["Dune"] = 1
}
go run ./labs/03-maps
The Hobbit | Middlemarch | Dune | Emma |
Dune | Emma | The Hobbit | Middlemarch |
Dune | Emma | The Hobbit | Middlemarch |
Dune=ok Emma=ok Middlemarch=low The Hobbit=out of stock
0 false
7
12
0 0
main finished
panic: assignment to entry in nil map

goroutine 1 [running]:
main.main()
    /home/user/bookstore/labs/03-maps/main.go:61 +0x4fc
exit status 2

First difference: we walked the same map three times and the first round came out in a different order. Go deliberately does not guarantee map iteration order and starts at a random point on each loop, so that programs do not rely on it. If you want sorted output, collect the keys and sort them: maps.Keys returns an iterator and slices.Sorted turns it into a sorted slice. We will look at iterators in detail in 06.

Second difference: reading a missing key does not throw an exception; it returns the zero value. stock["Ulysses"] gave 0, but "The Hobbit", with 0 copies in stock, also gives 0. To tell them apart there is the "comma-ok" idiom: n, ok := stock["Ulysses"] returns, as a second value, whether the key is in the map. It is the counterpart of C#'s TryGetValue.

Third difference: a nil map can be read but not written. The line var reserved map[string]int created no map; it only declared a variable whose zero value is nil. Reading returned 0, writing caused a panic. You have to create the map with make(map[string]int) or map[string]int{}. Notice that even with the panic, the "main finished" line we registered with defer was printed. The compiler does not catch this bug, but the staticcheck we added in 02 does; after deleting the //lint:ignore line in the lab:

go tool staticcheck ./...
labs/03-maps/main.go:60:2: assignment to nil map (SA5000)

Pointers: No Arithmetic, but new(expr)

The restockCopy and restock functions in the same lab show Go's most basic rule: everything is passed by value. restockCopy(b, 5) received a copy of the struct and increased the copy's stock; b.Stock stayed at 7. restock(&b, 5) passed the address of b; the function changed the actual variable through the pointer, and the stock became 12. What a ref parameter is for structs in C#, a pointer is in Go; all the copying rules in the C# Fundamentals series post "Value Types and Reference Types in C#" apply to Go structs as well. The difference: Go has no classes, so you decide whether a type behaves as a value or like a reference not when you define the type, but when you use it, by taking a pointer or not.

You do not need to dereference a pointer to reach a field: b.Stock works on both Book and *Book. There is no pointer arithmetic; you cannot move to the next memory address with p++. In other words, what C# calls unsafe code simply does not exist in Go by default. Returning the address of a local variable from a function is safe too; the compiler moves the variable to the heap when needed.

Since Go 1.26, new can take an expression. Previously, giving a constant value to an optional field needed an intermediate variable: d := 15; b.Discount = &d. Now you can write b.Discount = new(15). Pointer fields such as *int are used to carry the difference between "no value" and "value 0" in JSON; we will see this in 10.

Control Flow: One Loop Called for, switch and defer

Go has no while, do-while or foreach; for does all their jobs. for i := 0; i < n; i++ is the classic loop, for cond { } is while, for { } is an infinite loop, for i, v := range items is foreach. Since Go 1.22 you can also range over a number with for i := range 10.

The more important change in 1.22 is the meaning of the loop variable. It used to be a single variable for the whole loop; when closures captured it, they all saw the last value. Now every iteration gets its own variable. This behaviour depends on the module's go line, so we can run the same code with two different go lines:

package main

import "fmt"

func main() {
    titles := []string{"Dune", "Emma", "Ulysses"}
    var printers []func()
    for _, t := range titles {
        printers = append(printers, func() { fmt.Print(t, " ") })
    }
    for _, p := range printers {
        p()
    }
    fmt.Println()
}
go mod edit -go=1.26.0 && go run .
go mod edit -go=1.21.0 && go run .
Dune Emma Ulysses
Ulysses Ulysses Ulysses

Same toolchain, same code, different result. In the C# Advanced series post "Lambdas and Closures in C#: The Captured Variable Trap" we described how C# 5 made the same fix for foreach in 2012; Go did it in 2024 with 1.22 and tied it to the go line so as not to break old code. Since Bookstore is at go 1.26.0, the new behaviour applies.

Cases in a switch do not need break; the matching case runs and the switch ends. If you want to fall into the next case you write fallthrough, but in practice that is rarely needed. A switch without an expression, like the one in the lab's label function, is the readable form of a long if-else if chain. defer queues a function call to run when the surrounding function returns. It does the job of C#'s finally or using block and runs even on a panic; it is the main tool for closing resources. If there are several defers, they run in reverse order.

A Price Filter for the Catalog

Now we can return to Bookstore. I added price, author and stock to internal/catalog. I do not use float64 for money: Go has no standard counterpart of C#'s decimal, and float64 loses kuruş in sums like 0.1 + 0.2. The plainest solution is to keep the amount as an integer in kuruş. I declared a named type for it:

// Package catalog holds the books Bookstore sells.
package catalog

import "slices"

// Money is an amount in kuruş: 34990 means 349.90 TRY.
type Money int64

// Book is one title in the catalog.
type Book struct {
    ISBN   string `json:"isbn"`
    Title  string `json:"title"`
    Author string `json:"author"`
    Price  Money  `json:"price"`
    Stock  int    `json:"stock"`
}

// books is unexported: other packages can only reach it through Store.
var books = []Book{
    {ISBN: "9780441172719", Title: "Dune", Author: "Frank Herbert", Price: 34990, Stock: 12},
    {ISBN: "9780547928227", Title: "The Hobbit", Author: "J.R.R. Tolkien", Price: 28950, Stock: 0},
    {ISBN: "9780141439549", Title: "Middlemarch", Author: "George Eliot", Price: 31000, Stock: 4},
    {ISBN: "9780141439587", Title: "Emma", Author: "Jane Austen", Price: 19900, Stock: 7},
}

// Store serves books from memory for now.
type Store struct{}

// All returns a copy of every book, so callers cannot change the catalog.
func (Store) All() []Book {
    return slices.Clone(books)
}

// CheaperThan returns the books whose price is at most limit.
func (Store) CheaperThan(limit Money) []Book {
    result := []Book{} // empty, not nil: encodes as [] instead of null
    for _, b := range books {
        if b.Price <= limit {
            result = append(result, b)
        }
    }
    return result
}

Two changes come from what we learned in this post. In 02, All returned the package-level books slice directly; had a caller written store.All()[0].Price = 0, the catalog itself would have changed, because the returned slice looked at the same backing array. Now slices.Clone returns a copy. CheaperThan also writes its result into a new slice instead of on top of the input.

In the handler we read the maxPrice query parameter and convert it to a number. strconv.ParseInt returns two values: the number and an error. It is the counterpart of C#'s long.TryParse, but it reports failure with an error rather than a bool:

http.HandleFunc("GET /api/books", func(w http.ResponseWriter, r *http.Request) {
    list := store.All()
    if s := r.URL.Query().Get("maxPrice"); s != "" {
        limit, err := strconv.ParseInt(s, 10, 64)
        if err != nil {
            http.Error(w, "maxPrice must be a whole number of kurus", http.StatusBadRequest)
            return
        }
        list = store.CheaperThan(catalog.Money(limit))
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(list)
})

The if s := ...; s != "" form is common in Go: the variable only lives in the scope of the if block. catalog.Money(limit) is an explicit type conversion; int64 does not turn into Money on its own. Let's try three requests (I removed the Date headers):

curl -i "http://127.0.0.1:52710/api/books?maxPrice=30000"
curl -i "http://127.0.0.1:52710/api/books?maxPrice=abc"
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 185

[{"isbn":"9780547928227","title":"The Hobbit","author":"J.R.R. Tolkien","price":28950,"stock":0},{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","price":19900,"stock":7}]
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 41

maxPrice must be a whole number of kurus

For the third request I gave a limit that no book matches. In the first version of CheaperThan I had started the result with var result []Book. With no book appended, the function returned a nil slice, and encoding/json wrote it not as an empty array but as null:

curl -i "http://127.0.0.1:52710/api/books?maxPrice=100"
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 5

null

Inside Go, a nil slice and an empty slice behave almost the same: both have a len of 0 and both accept append. But in JSON one is null and the other []. If the Angular client tries to use this response as an array, it fails. Starting with result := []Book{}, the same request returned [] and the Content-Length became 3.

In Production

When you hand a slice outside the package, think about who owns it. Go has no read-only collection type; you cannot return a contract like C#'s IReadOnlyList<T>. The contents of a slice you hand out can be changed. If you hand out shared data, give a copy; the copy has a memory cost, but it is cheaper than shared state changing quietly. The same rule applies in the other direction: if you take a slice as a parameter and keep it, know that the caller may change it later.

Start slices that go into JSON as empty. If your API contract says "this field is always an array", nil slices quietly produce null. encoding/json/v2 in Go 1.27 changes the default here: it writes a nil slice as an empty array, and the FormatNilSliceAsNull option restores the old behaviour. We will discuss it in 10.

Do not write to a map from several goroutines at once. Go's map, like C#'s Dictionary, is not thread-safe; if the runtime notices concurrent writes, it stops the program with a "concurrent map writes" error. We will solve this with a mutex in 08.

Common Pitfalls

Thinking a sub-slice is an independent copy. books[:2], books[1:] or books[:0] is a new slice header but the same backing array. If you want a copy, use slices.Clone.

Not assigning the result of append. Writing append(titles, "Dune") on its own is a compile error, because the result is unused. But writing other := append(titles, "Dune") and carrying on with titles is a logic bug: the new element is in other.

Taking len(s) for the number of letters. Turkish characters take more than one byte in UTF-8. If you cut a title shown to users at 30 characters by byte index, you may split a letter in half and produce invalid UTF-8.

Writing to a nil map and relying on map order. Create maps with make; if you need sorted output, sort the keys. A test that relies on order passes sometimes and fails sometimes.

float64 for money. Use an integer in kuruş or a decimal library; in 10 we will discuss this choice together with the JSON side.

A Small Experiment

★☆☆ Rewrite cheaperInPlace in labs/03-filter so that it does not touch its input. Try two ways: opening a new slice with make([]Book, 0, len(books)), and copying with slices.Clone first and then using slices.DeleteFunc.

★★☆ Add a function to labs/03-basics that cuts a Turkish book title to at most 5 letters. Try it first with s[:5] and look at the output for "Çalıkuşu"; then fix it with a []rune(s) conversion.

★★★ Add an InStock() []Book method to catalog that returns the books in stock, and wire it to the handler with GET /api/books?inStock=true. When given together with maxPrice, both filters should apply. Check that the response is [] when no book matches.

We have seen how types, slices and maps behave. In the next post we move on to the structs, methods and interfaces that bring them together: building the order model without classes, the difference between value and pointer receivers, embedding and Go's implicit interfaces. We will add the internal/orders package to Bookstore.