Notes · Learn Go › LESSON 2 OF 8

Types & Variables

Go knows what type every variable is - at compile time, not at runtime. That sounds like a constraint, but it's actually what catches whole categories of bugs before your program ever runs. If you've only used Python, this lesson will rewire one key habit.

Contents
  1. var, const, and :=
  2. Go's basic types
  3. Zero values: no null surprises
  4. Type inference with :=
  5. Type conversions (not casts)
  6. Constants and iota
  7. Real-world examples
  8. Python-dev gotchas
  9. Glossary

Variables: var, const, :=

Go gives you three ways to declare a variable. Python gives you one (x = 5). The three Go forms exist for different reasons - they're not just stylistic choices.

Python  ·  vars.py
# Python: one form, always
x = 5
name = "Sunil"
pi = 3.14

# Python figures out type at runtime
# x could be reassigned to anything:
x = "now I'm a string"  # totally fine
Go  ·  vars.go
// Go: three forms, each with a purpose
var x int = 5          // explicit type
var name string        // zero value ""
x2 := 5               // inferred type
const Pi = 3.14       // immutable

// x is always int. This won't compile:
// x = "now I'm a string"  <-- ERROR

The three declaration forms

var x int = 5

Long form. Fully explicit: name, type, and value. Verbose but clear. Good when you want the type to be obvious to the reader.

var count int = 0
var enabled bool = true
var msg string = "hi"

Also fine: var count int - gets zero value.

x := 5

Short form (most common). Go infers the type from the right-hand side. Only works inside a function body.

count := 0         // int
enabled := true   // bool
msg := "hi"       // string
ratio := 3.14     // float64

This is what you'll write 90% of the time.

const Pi = 3.14

Constants. Value is fixed at compile time. Can't be changed at runtime. Works at package level or inside functions.

const MaxRetries = 3
const AppName = "myapp"
const Pi float64 = 3.14159

Only basic types: no slices, no maps.

When to use each form

FormUse whenScope
var x T = v You want the type to be explicit in the source, or you need a zero value without a starting value (just var x T). Package level or function body
x := v Inside a function, almost always. Keeps code concise and readable. Function body only
const C = v Values that must never change: limits, enum-like values, app-wide settings. Package level or function body
The 5-year-old version
Think of a variable as a labeled box that holds a toy.

var x int = 5 is like writing "THIS BOX HOLDS ONLY LEGO BRICKS" on the outside, then putting 5 bricks in. The label is there forever - you can change the number of bricks, but you can't put Play-Doh in the Lego box.

x := 5 is the shortcut: you put the bricks in and Go writes the label for you, based on what you put inside.

const Pi = 3.14 is a box that's been super-glued shut. Nobody can change what's inside. Ever. The compiler guarantees it.
Choosing a declaration form New variable? value known now? no / need zero value var x T e.g. var count int Should it be immutable? yes const C = v compile-time only no x := v (short declaration)
The short form := handles most cases. Use var when you need a zero value, and const when the value must never change.

Go's basic types

Go's type system is larger than Python's built-ins but smaller than C's. You'll use about half a dozen types daily. Here's the full map:

Go Types built-in + composite Numbers integers, floats, complex Text string, rune, byte Bool true / false only int (signed) int8, int16 int32, int64 uint, uint8.. int = 32 or 64 bit float float32 float64 use float64 complex complex64 complex128 rarely used string UTF-8 bytes immutable like Python str rune = int32 one char like Python chr byte = uint8 one byte like Python bytes[i] bool true false no truthy/falsy composite array slice map struct pointer Lessons 5 & 6 Daily drivers (highlighted): int float64 string bool rune
Go's type tree. The composite types (slice, map, struct) are covered in Lessons 5 and 6.

Python equivalents at a glance

Go typePython equivalentNotes
intintPlatform-sized: 32-bit on 32-bit OS, 64-bit on 64-bit OS. Use this by default.
int8 / int16 / int32 / int64no direct equivalentFixed-width integers. Use when you need a specific bit width (e.g., Kubernetes API uses int32).
uint / uint8 / uint16 / uint32 / uint64no direct equivalentUnsigned integers. uint8 and byte are identical.
float64floatDefault for floating-point. Almost always what you want. float32 exists but loses precision.
stringstrImmutable sequence of UTF-8 bytes. Indexing gives a byte, not a character.
boolboolOnly true / false. No truthy/falsy. if 1 {} is a compile error.
byteint (0-255)Alias for uint8. Used heavily in I/O, networking, and file handling.
runestr (single char)Alias for int32. Represents one Unicode code point. Use when iterating characters (not bytes) in a string.
Python - dynamic typing
# Python doesn't care about types at assignment
x = 42          # int
x = 3.14        # float now - totally fine
x = "hello"     # str now - still fine
x = [1, 2, 3]   # list now - sure, why not

# Bug might appear far from the assignment:
result = x + 1  # TypeError at runtime!
Go - static typing
// Go knows the type from the first assignment
x := 42          // int, locked in
x = 100          // fine, still int
// x = 3.14      // compile error!
// x = "hello"   // compile error!

// Bug is caught immediately, at compile time:
// result := x + "suffix" // error here
int vs int64 vs int32: When in doubt, use plain int. Only reach for int64 or int32 when an external API or protocol requires a specific width - like Kubernetes resource fields, or when reading binary network data.

Zero values: the death of None/null

This is one of the most important Go concepts for Python developers. In Python, a variable that hasn't been assigned a value simply doesn't exist - accessing it is a NameError. In Go, every declared variable always has a value. If you don't give it one, Go gives it a sensible default called the zero value.

Zero values - what you get when you don't initialize Type Zero value Declared as Python comparison int, int64, uint 0 var n int no direct equiv. Python raises NameError float64, float32 0.0 var f float64 no direct equiv. Python raises NameError string "" var s string closest: s = "" bool false var b bool closest: b = False pointer, slice map, interface nil var p *int var s []int None - but only for reference types
Every type has a predictable zero value. var n int guarantees n is 0 - never garbage, never a surprise.
The 5-year-old version
In Python, if you want a variable, you have to put something in it first. Ask for it before that and Python throws an error - like knocking on a door that doesn't exist yet.

In Go, all the doors exist from the moment you declare the variable. A number door always starts at zero. A text door always starts as an empty string. A true/false door always starts as false.

It's like a fresh notebook: the pages are blank, but they're there. Python would give you a notebook with the pages literally missing. Go's notebook just has blank pages - you can start writing immediately.

Why this matters in practice

zerovalues.go
package main

import "fmt"

func main() {
    var count int      // = 0, not uninitialized
    var name string   // = "", safe to use immediately
    var ready bool    // = false, predictable

    // This is safe - no nil-pointer, no NameError:
    fmt.Println(count, name, ready) // 0  false

    // Accumulating without initialization is safe:
    count += 10
    fmt.Println(count) // 10
}
Note on nil: Reference types (pointers, slices, maps, channels, interfaces) do have nil as their zero value. nil is not the same as Python's None - you can't assign nil to an int. nil only applies to reference types, and calling methods on a nil map or interface will panic. For basic types (int, string, bool), there is no null/None.

Type inference with :=

When you write x := 42, Go doesn't skip type checking - it infers the type from the right-hand side at compile time. The variable is still statically typed; Go just figures out the type for you so you don't have to type it twice.

inference.go
package main

import "fmt"

func main() {
    i   := 42           // inferred: int
    f   := 3.14         // inferred: float64
    s   := "hello"      // inferred: string
    b   := true          // inferred: bool
    r   := 'A'           // inferred: rune (int32)

    // The types are locked in immediately:
    fmt.Printf("%T %T %T %T %T\n", i, f, s, b, r)
    // Output: int float64 string bool int32

    // Multi-value short declaration:
    x, y := 10, 20
    fmt.Println(x + y) // 30
}

What Go infers from each literal

LiteralInferred typeExample
42 (integer)intn := 42
3.14 (decimal)float64f := 3.14
"hello" (double quotes)strings := "hello"
'A' (single quotes)rune (= int32)r := 'A'
true / falseboolb := true
nilcannot infer - need explicit typevar p *int
:= is only valid inside functions. At package level you must use var. This is a hard rule enforced by the compiler. The most common mistake when learning Go is writing x := 5 at the top of a file, outside any function - that's a syntax error.
packagelevel.go
package main

var host = "localhost" // OK: var at package level
// port := 8080         // NOT OK: := at package level

func main() {
    port := 8080         // OK: := inside function
    _ = port
}
:= vs = - the most common early mistake.

:= declares and assigns. = only assigns to an already-declared variable. If you use := on a variable that already exists in the same scope, the compiler complains. But if it shadows an outer scope variable - it silently creates a new local one. Always check your scope when something isn't updating as expected.

Type conversions (not casts)

Go has zero implicit type conversions. None. If you have an int32 and need an int64, you must convert explicitly. This feels annoying at first, then you realize it has saved you from a whole class of subtle bugs.

Python - implicit conversions
# Python happily mixes types
x = 5           # int
y = 2.0         # float
z = x + y       # 7.0 - Python promotes silently

# Strings from ints:
n = 42
s = str(n)       # "42" - one call

# Strings to ints:
s = "42"
n = int(s)       # 42 - raises ValueError on bad input
Go - explicit only
// Go requires explicit conversion
var x int = 5
var y float64 = 2.0
// z := x + y      // compile error!
z := float64(x) + y  // 7.0 - explicit

// Ints to strings - NOT int(n):
n := 42
s := fmt.Sprintf("%d", n)  // "42"

// Strings to ints - returns (int, error):
n2, err := strconv.Atoi("42")

Common conversion patterns

conversions.go
package main

import (
    "fmt"
    "strconv"
)

func main() {
    // Numeric conversions - explicit T(x) syntax
    i := 42
    f := float64(i)      // int -> float64
    u := uint(i)         // int -> uint (watch out for negatives)
    i32 := int32(i)     // int -> int32 (possible truncation)
    _ = f; _ = u; _ = i32

    // string <-> []byte (common in HTTP, file I/O)
    s := "hello"
    b := []byte(s)      // string -> []byte
    s2 := string(b)     // []byte -> string
    _ = s2

    // string <-> int: use strconv, not int(s)
    n, err := strconv.Atoi("123")  // "123" -> 123
    if err != nil {
        fmt.Println("bad number")
        return
    }
    fmt.Println(n + 1)             // 124

    s3 := strconv.Itoa(456)        // 456 -> "456"
    fmt.Println(s3)

    // int(byteValue) gives you the numeric value,
    // NOT the ASCII string - this trips everyone up
    byt := b[0]                   // byte 'h' = 104
    fmt.Println(int(byt))          // 104, NOT "h"
    fmt.Println(string(byt))       // "h"
}
string(42) does NOT give you "42".

In Go, string(42) converts the integer 42 to the Unicode character at code point 42, which is "*". To convert an int to its string representation, use strconv.Itoa(42) or fmt.Sprintf("%d", 42). This is one of the top-five Go surprises for Python developers.

What you wantCorrect Go codeWrong - don't do this
int 42 to string "42"strconv.Itoa(42)string(42) - gives "*"
string "42" to int 42strconv.Atoi("42")int("42") - compile error
int to float64float64(n)n + 0.0 - compile error
float64 to int (truncates)int(f)implicit - compile error
string to []byte[]byte(s)no Python analog

Constants and iota

Constants in Go are exactly what the name says: values that cannot change at runtime. They're evaluated at compile time. The compiler verifies that nothing ever modifies them.

constants.go
package main

// Untyped constant - flexible, can be used with any compatible type
const MaxConnections = 100

// Typed constant - locked to a specific type
const Pi float64 = 3.14159265358979

// Grouped constants (common pattern)
const (
    AppName    = "myapp"
    AppVersion = "1.0.0"
    MaxRetries = 3
)

func main() {
    // MaxConnections = 200  // compile error: cannot assign to const

    // Untyped const MaxConnections can be used as int or int64:
    var x int64 = MaxConnections    // fine
    var y int   = MaxConnections    // also fine
    _, _ = x, y
}

iota: enum-like constants without the boilerplate

iota is a special compiler counter that resets to 0 at each const block and increments by 1 for each constant in the group. It's Go's idiomatic way to create enum-like values without a separate enum keyword.

iota.go
package main

import "fmt"

// Log levels - the classic iota example
type LogLevel int

const (
    Debug  LogLevel = iota // 0
    Info                   // 1 (repeats the expression)
    Warn                   // 2
    Error                  // 3
)

// Bit flags using iota with a shift expression
const (
    ReadPerm  = 1 << iota // 1 (1 << 0)
    WritePerm               // 2 (1 << 1)
    ExecPerm                // 4 (1 << 2)
)

func main() {
    level := Info
    fmt.Println(level)    // 1

    perms := ReadPerm | WritePerm
    fmt.Println(perms)   // 3
}

Typed vs untyped constants

Untyped constantTyped constant
Declaration const Max = 100 const Max int32 = 100
Flexibility Can be assigned to any compatible numeric type Only assignable to exactly int32
Use case General limits, config values API fields that require a specific width
In expressions Go picks the right numeric type Requires explicit conversion to use with other types
Constants can only be basic types. You cannot write const names = []string{"a", "b"}. Constants must be numbers, strings, or booleans. For "constant slices" or "constant maps," Go developers use package-level var declarations by convention (and document them as read-only).

Real-world examples

Types aren't just academic. Here's how what you learned shows up in real Go code:

BackendConfig struct loaded from environment variables

Loading typed config from env variables - every backend service does this:

config.go
package main

import (
    "fmt"
    "os"
    "strconv"
)

type Config struct {
    Host        string
    Port        int
    MaxRetries  int
    Debug       bool
}

func loadConfig() (Config, error) {
    portStr := os.Getenv("PORT")
    if portStr == "" {
        portStr = "8080"  // zero value default
    }

    port, err := strconv.Atoi(portStr)  // string -> int, with error
    if err != nil {
        return Config{}, fmt.Errorf("invalid PORT %q: %w", portStr, err)
    }

    return Config{
        Host:       os.Getenv("HOST"),     // "" if unset - zero value, fine
        Port:       port,
        MaxRetries: 3,                      // untyped const works as int
        Debug:      os.Getenv("DEBUG") == "true",
    }, nil
}

Notice: strconv.Atoi returns (int, error) - the Go way to handle conversion failures. No exceptions; you check the error explicitly. (More on errors in Lesson 7.)

CLIParsing a flag and converting string to int

A CLI tool that limits concurrency - types matter when you pass values to APIs:

worker.go
package main

import (
    "flag"
    "fmt"
)

const defaultWorkers = 4  // untyped int constant

func main() {
    // flag.Int returns *int, not int - a pointer
    workers := flag.Int("workers", defaultWorkers, "number of workers")
    verbose := flag.Bool("verbose", false, "enable verbose logging")
    flag.Parse()

    // Dereference the pointer to get the int value:
    fmt.Printf("Starting %d workers (verbose=%v)\n", *workers, *verbose)

    // Convert int to int32 if an API requires it:
    workerCount := int32(*workers)
    _ = workerCount
}
terminal
$ go run worker.go --workers=8 --verbose
Starting 8 workers (verbose=true)
k8sint32 pointer for a Deployment Replicas field

The Kubernetes Go client uses *int32 for the Replicas field - a typed pointer that distinguishes "zero replicas" from "not set":

deployment.go
package main

import (
    appsv1 "k8s.io/api/apps/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func newDeployment(name string, replicas int32) *appsv1.Deployment {
    // The Replicas field is *int32, not int32.
    // nil means "let the controller decide".
    // 0 means "scale down to zero".
    // They are different! This is why it's a pointer.
    r := replicas  // make a copy to take address of

    return &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{Name: name},
        Spec: appsv1.DeploymentSpec{
            Replicas: &r,  // *int32: address of our int32
        },
    }
}

func main() {
    var count int = 3
    // Must convert int to int32 - they are different types in Go:
    d := newDeployment("my-app", int32(count))
    _ = d
}

This is exactly why Go has fixed-width integer types: the Kubernetes API spec defines replicas as a 32-bit integer. Go enforces that you use int32 specifically - int won't compile.

Python-dev gotchas (read twice)

1. You cannot assign a string to an int variable.

After x := 5, trying x = "hello" is a compile error - not a runtime error. Python lets you reassign any variable to any type. Go locks the type at first assignment and never lets go. The compiler is your type enforcer.

2. Mixing int and int64 requires explicit conversion.

var a int = 5; var b int64 = 10; _ = a + b - compile error. Even though both are integers, they are different types. You must write int64(a) + b. This is the most common friction point when working with external libraries that use specific widths.

3. := redeclares - the := vs = mix-up.

If you mean to update an existing variable, use =. If you accidentally use := inside a nested scope (like an if body), you create a new variable that shadows the outer one. The outer variable doesn't change, and the compiler won't warn you unless the inner one goes unused.

err := doSomething()
if err != nil {
    result, err := recover()  // new 'err' - shadows outer!
    _ = result; _ = err
}
// outer err is still the original - did you mean '='?
4. Constants can't be slice or map literals.

const names = []string{"a", "b"} is a compile error. Constants are restricted to basic types: booleans, numbers, and strings. For "effectively constant" slices or maps, Go developers use a package-level var and rely on convention (and code review) to treat them as read-only.

5. String indexing returns a byte, not a character.

s := "hello"; s[0] returns 104 (the byte value of 'h'), not the string "h". This matches Python's bytes[0], not Python's str[0]. For character-by-character iteration, use for i, ch := range s - ch will be a rune, which handles multi-byte Unicode characters correctly.

Glossary

TermMeaning
varExplicit variable declaration. Works at package level or inside functions. Gives the variable its zero value if no initializer is provided.
constDeclares an immutable value evaluated at compile time. Only basic types allowed. Cannot be reassigned at runtime.
:=Short variable declaration. Declares and assigns in one step; type is inferred from the right-hand side. Only valid inside function bodies.
zero valueThe default value Go gives every variable that is declared without an explicit initializer. int gets 0, string gets "", bool gets false, reference types get nil.
type inferenceGo's ability to determine a variable's type from its initializer at compile time, used with :=. The variable is still statically typed; the type is just not written out explicitly.
untyped constantA constant declared without an explicit type (e.g., const Max = 100). It can be used with any compatible numeric type and the compiler picks the right one at the point of use.
iotaA compiler-provided counter in const blocks, starting at 0 and incrementing by 1 per constant. Used to create enum-like sequences without repeating values.
runeAn alias for int32. Represents a single Unicode code point. Use when iterating over the characters (not raw bytes) of a string.
byteAn alias for uint8. The element type of a []byte slice. String indexing and many I/O operations work with bytes.
type conversionExplicitly changing a value from one type to another using the syntax T(v). Go has no implicit conversions - all numeric type changes must be explicit.

Origins, gotchas, and a quick check

Backstory and pitfalls that make this chapter stick.

What's in a name?
int / int64
Sized signed integers. int is platform-width (64 bits on most modern systems). For counters/timestamps prefer int64 explicitly.
string
UTF-8 byte sequence, immutable. Indexing returns bytes, not characters. len(s) is byte count, not rune count.
byte
Alias for uint8. Use when you mean "a byte" semantically (e.g., reading from an io.Reader).
rune
Alias for int32. Represents a Unicode code point. Named after the Old Norse runic alphabet (single characters).
const
Compile-time constants. Cannot be addressed (&myConst is illegal).
var
Declares a variable. Verbose: var x int = 5. Short: x := 5 (inside functions only).
:=
Short variable declaration. Declares + assigns, inferring type. Cannot be used at package level.
iota
The constant generator. Starts at 0, increments by 1 per ConstSpec in a const block. Used for enum-like patterns.
Fun fact Go strings are immutable byte sequences storing UTF-8, which means len(s) returns the number of bytes, not characters. len("hello") is 5, but len("café") is 5 too (é is 2 bytes in UTF-8 so 'caf' is 3 bytes + é is 2 = 5). To count characters, range over the string (which yields runes) or use utf8.RuneCountInString. This trips up almost every Python developer on day 1 - Python's len() counts characters, Go's len() counts bytes.
Bug hunt

What does this program print? (Hint: only one of the two if-statements enters the truthy branch.)

nil-interface.go
package main

import "fmt"

type MyError struct{}
func (MyError) Error() string { return "oops" }

func main() {
    var i interface{} = nil
    if i == nil {
        fmt.Println("i is nil")
    }

    var e *MyError = nil
    var ie interface{} = e
    if ie == nil {
        fmt.Println("ie is nil")
    } else {
        fmt.Println("ie is NOT nil:", ie)
    }
}
Click to reveal the bug

Output:

output
i is nil
ie is NOT nil: <nil>

The interface ie holds a typed-nil ((*MyError)(nil)). The interface itself is not nil because it has a type assigned. Only when BOTH the type AND value are nil does an interface compare equal to nil. To avoid: never assign a typed nil to an interface return value. If your function returns error, return raw nil, not a typed nil cast to error.

Real-world incident The int32 overflow at 2.1B 4-hour autoscaler outage

A trading platform built a counter for "messages processed today" using var count int32. At ~2.1 billion messages, it overflowed to a negative number. Dashboards broke first. Then the autoscaler that watched the counter scaled down to zero workers because "count is negative, must be a bug, throttle". Took 4 hours to find and roll back.

counter.go
// Wrong: silently overflows at ~2.1B
var count int32

// Right: 9.2 quintillion ceiling, modern CPUs handle int64 just as fast
var count int64

Lesson: Default to int64 for counters, timestamps, and any value you can't bound at compile time. The 4-byte savings of int32 is rarely worth the silent overflow risk. Modern CPUs handle 64-bit arithmetic at the same speed as 32-bit.

Quick check

Test yourself

Don't peek. Think first, then click to reveal each answer.

1. What's the zero value of a string in Go?

  • nil
  • "" (empty string)
  • Undefined - reading it panics
  • null
Show answer
Answer: b. Empty string. Reading an uninitialized string returns "", not nil. Same for any basic type: int is 0, bool is false, struct is all-fields-zero.

2. x := 42 declares x as what type?

  • int
  • int32
  • int64
  • Depends on the platform
Show answer
Answer: a. Untyped integer literals default to int. The platform-specific bit-width of int (32 vs 64) is separate - the inferred type name is just int.

3. Which is a VALID Go constant declaration?

  • const Pi = 3.14
  • const Pi float64 = 3.14
  • const Pi = math.Sqrt(2)
  • a and b only
Show answer
Answer: d. Constants must be compile-time evaluable. math.Sqrt is a function call, evaluated at runtime, so it cannot be a constant. The first two work fine.

4. len("café") returns what?

  • 4
  • 5
  • Depends on the encoding
  • Compile error
Show answer
Answer: b. len on a string returns bytes. The é is 2 bytes in UTF-8, so c-a-f are 3 bytes + é is 2 = 5 total. To count characters, range over the string or use utf8.RuneCountInString.

5. The iota keyword inside a const block...

  • Imports a package
  • Generates sequential integers, one per ConstSpec, resetting per block
  • Marks a constant as exported
  • Locks the constant from being shadowed
Show answer
Answer: b. iota is a constant generator. Starts at 0 in each const(...) block, increments by 1 per line. Used for enum-like declarations: const ( Red = iota; Green; Blue ) gives 0, 1, 2.