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.
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: 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: 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
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.
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.
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.
| Form | Use when | Scope |
|---|---|---|
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 |
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.
:= handles most cases. Use var when you need a zero value, and const when the value must never change.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 type | Python equivalent | Notes |
|---|---|---|
int | int | Platform-sized: 32-bit on 32-bit OS, 64-bit on 64-bit OS. Use this by default. |
int8 / int16 / int32 / int64 | no direct equivalent | Fixed-width integers. Use when you need a specific bit width (e.g., Kubernetes API uses int32). |
uint / uint8 / uint16 / uint32 / uint64 | no direct equivalent | Unsigned integers. uint8 and byte are identical. |
float64 | float | Default for floating-point. Almost always what you want. float32 exists but loses precision. |
string | str | Immutable sequence of UTF-8 bytes. Indexing gives a byte, not a character. |
bool | bool | Only true / false. No truthy/falsy. if 1 {} is a compile error. |
byte | int (0-255) | Alias for uint8. Used heavily in I/O, networking, and file handling. |
rune | str (single char) | Alias for int32. Represents one Unicode code point. Use when iterating characters (not bytes) in a string. |
# 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 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. 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.
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.
var n int guarantees n is 0 - never garbage, never a surprise.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
}
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.
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.
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
}
| Literal | Inferred type | Example |
|---|---|---|
42 (integer) | int | n := 42 |
3.14 (decimal) | float64 | f := 3.14 |
"hello" (double quotes) | string | s := "hello" |
'A' (single quotes) | rune (= int32) | r := 'A' |
true / false | bool | b := true |
nil | cannot infer - need explicit type | var p *int |
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.
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
}
:= 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.
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 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 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")
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"
}
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 want | Correct Go code | Wrong - don't do this |
|---|---|---|
| int 42 to string "42" | strconv.Itoa(42) | string(42) - gives "*" |
| string "42" to int 42 | strconv.Atoi("42") | int("42") - compile error |
| int to float64 | float64(n) | n + 0.0 - compile error |
| float64 to int (truncates) | int(f) | implicit - compile error |
| string to []byte | []byte(s) | no Python analog |
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.
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 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.
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
}
| Untyped constant | Typed 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 |
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).
Types aren't just academic. Here's how what you learned shows up in real Go code:
Loading typed config from env variables - every backend service does this:
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.)
A CLI tool that limits concurrency - types matter when you pass values to APIs:
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
}
$ go run worker.go --workers=8 --verbose
Starting 8 workers (verbose=true)
The Kubernetes Go client uses *int32 for the Replicas field - a typed pointer that distinguishes "zero replicas" from "not set":
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.
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.
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.
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 '='?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.
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.
| Term | Meaning |
|---|---|
var | Explicit variable declaration. Works at package level or inside functions. Gives the variable its zero value if no initializer is provided. |
const | Declares 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 value | The 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 inference | Go'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 constant | A 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. |
iota | A compiler-provided counter in const blocks, starting at 0 and incrementing by 1 per constant. Used to create enum-like sequences without repeating values. |
rune | An alias for int32. Represents a single Unicode code point. Use when iterating over the characters (not raw bytes) of a string. |
byte | An alias for uint8. The element type of a []byte slice. String indexing and many I/O operations work with bytes. |
| type conversion | Explicitly 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. |
Backstory and pitfalls that make this chapter stick.
int is platform-width (64 bits on most modern systems). For counters/timestamps prefer int64 explicitly.len(s) is byte count, not rune count.uint8. Use when you mean "a byte" semantically (e.g., reading from an io.Reader).int32. Represents a Unicode code point. Named after the Old Norse runic alphabet (single characters).&myConst is illegal).var x int = 5. Short: x := 5 (inside functions only).const block. Used for enum-like patterns.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.
What does this program print? (Hint: only one of the two if-statements enters the truthy branch.)
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)
}
}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.
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.
// Wrong: silently overflows at ~2.1B
var count int32
// Right: 9.2 quintillion ceiling, modern CPUs handle int64 just as fast
var count int64Lesson: 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.
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)null"", 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?
intint32int64int. 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.14const Pi float64 = 3.14const Pi = math.Sqrt(2)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?
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...
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.