Go Fundamentals: nil Slices, Interface Type Assertions, and Goroutine Scheduling

2 min readProgramming

Three Go behaviors that surprise developers from other languages: nil slices are safe to use (unlike null pointers), type assertions and type conversions on interfaces have different runtime semantics, and goroutines are multiplexed onto OS threads by the Go scheduler — not mapped 1:1 like pthreads.

gogoroutinesconcurrency

nil slice vs empty slice

A Go slice is a three-word struct: pointer to backing array, length, and capacity.

var nilSlice []int        // nilSlice = {ptr: nil, len: 0, cap: 0}
emptySlice := []int{}     // emptySlice = {ptr: 0x..., len: 0, cap: 0}

fmt.Println(nilSlice == nil)   // true
fmt.Println(emptySlice == nil) // false
fmt.Println(len(nilSlice))     // 0 — safe
fmt.Println(len(emptySlice))   // 0

// Both are safe to append to:
nilSlice = append(nilSlice, 1)     // works, allocates backing array
emptySlice = append(emptySlice, 1) // works

nil slice and empty slice behave identically for len, cap, range, and append. The only behavioral difference is nilSlice == nil returns true. The practical difference shows up in JSON serialization:

type Response struct {
    Items []string `json:"items"`
}

// nil slice → JSON: {"items": null}
// empty slice → JSON: {"items": []}

If API contracts require [] instead of null, initialize with []string{} or make([]string, 0).

Type assertion vs type conversion on interfaces

Type assertion (x.(T)) and type conversion (T(x)) look similar but have different semantics when interfaces are involved.

Type conversion — checked at compile time, requires structural compatibility:

type Stringer interface {
    String() string
    Print() string
}
type Printer interface {
    Print() string
}

var s Stringer = MyType{"hello"}
p := Printer(s)  // OK: Stringer includes all methods of Printer

This compiles because Stringer has a superset of Printer's methods. The assignment is a no-op on nil values — a nil Stringer converts to a nil Printer without panicking.

Type assertion — checked at runtime, asserts the concrete type:

var s Stringer = MyType{"hello"}
p := s.(Printer)  // OK: the concrete type MyType implements Printer

Type assertion panics if the concrete type doesn't implement the target interface, or if the interface value is nil:

var s Stringer       // nil interface
p := s.(Printer)     // PANIC: interface conversion: interface is nil, not main.Printer

Use the two-value form to avoid panics:

p, ok := s.(Printer)
if !ok {
    // s doesn't hold a value that implements Printer
}

Nil interface vs nil concrete pointer inside interface — a frequent source of Go bugs

GotchaGo

An interface value is nil only when both its type and value fields are nil. A non-nil interface can hold a nil pointer. This means checking `err != nil` can return true even when the concrete error pointer is nil, causing unexpected behavior.

Prerequisites

  • Go interfaces
  • Go pointers
  • nil in Go

Key Points

  • Interface internals: (type, value) pair. Nil interface = both type and value are nil.
  • var err error = (*MyError)(nil) — err != nil is true because the type field is set, even though the pointer is nil.
  • Type assertion on nil interface panics; type conversion of nil interface to a wider interface produces nil without panic.
  • Always return untyped nil (return nil) from error-returning functions, never (*MyError)(nil).
// Classic bug: returning a typed nil
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

func doWork() error {
    var err *MyError  // typed nil pointer
    return err        // returns non-nil interface!
}

func main() {
    err := doWork()
    if err != nil {
        fmt.Println("got error")  // prints "got error" even though *MyError is nil
    }
}

Fix: return nil (untyped nil), not a typed nil pointer.

Goroutines and the Go scheduler

Goroutines are not OS threads. The Go runtime multiplexes N goroutines onto M OS threads (the N:M scheduler):

// Starting a goroutine — cheap (~2KB stack, grows dynamically)
go func() {
    fmt.Println("running concurrently")
}()

// Starting an OS thread (via C FFI or cgo) — expensive (~1-8MB stack, fixed)

The Go scheduler (since Go 1.14) is preemptive — a goroutine running an infinite loop can be preempted. Before 1.14, it was cooperative: goroutines yielded only at function calls or blocking operations, meaning tight loops could starve other goroutines.

GOMAXPROCS controls the number of OS threads that can execute Go code simultaneously:

import "runtime"

runtime.GOMAXPROCS(4)  // use 4 OS threads
// Default: runtime.NumCPU()
📝Package-level code restrictions and init()

Go allows variable declarations at package level but not assignments or operations:

var globalVar int  // OK: declaration with zero value

// ERROR: cannot assign to x outside a function
globalVar = 10

// OK: complex initialization goes in init()
func init() {
    globalVar = computeStartupValue()
}

init() runs after all package-level variable declarations are evaluated but before main(). Multiple init() functions can exist in a package (even across files); they run in source file order. init() cannot be called explicitly.

Why the restriction? Package-level code is executed when the package is imported, potentially from multiple goroutines during parallel initialization. Restricting it to declarations prevents implicit ordering bugs and keeps initialization deterministic.

A function returns error type but internally creates a *MyError variable and returns it when nil. The caller checks `if err != nil` and sees an error even though no error occurred. What's happening?

medium

Go interfaces store both a type and a value. A nil pointer of type *MyError is not the same as an untyped nil.

  • AThe error check is broken — err != nil is unreliable for custom error types
    Incorrect.err != nil is reliable — it checks if the interface contains a type and value. The problem isn't the check, it's what the function returns.
  • BThe function returns a non-nil interface value because var err *MyError sets the interface's type field to *MyError even when the pointer is nil. The interface is non-nil even though the pointer inside it is nil.
    Correct!An interface value is nil only when both its type and value fields are nil. var err *MyError = nil assigns a typed nil: type=*MyError, value=nil. When this is returned as error, the interface is (type=*MyError, value=nil) — not nil. err != nil returns true. Fix: return untyped nil — `return nil` — which sets both type and value to nil, resulting in a genuinely nil interface.
  • CThe issue is that *MyError doesn't properly implement the error interface
    Incorrect.If *MyError didn't implement error, the code wouldn't compile. The issue is nil interface semantics, not interface implementation.
  • DGo's garbage collector is collecting the error before the nil check
    Incorrect.Go's GC doesn't collect live objects. This is a nil interface semantics issue, not a memory management issue.

Hint:What are the two components of a Go interface value, and when is an interface value nil?