Every Go variable starts with a zero value, numbers never convert on their own, and constants are more flexible than variables. Learn var, :=, sized integers, overflow, iota and the Printf verbs for inspecting values.
Go is strict about types in a few places where other languages are relaxed. An int never quietly turns into a float64. A variable is never left holding garbage. A constant that doesn’t fit is caught before the program runs.
This post covers declaring variables, the basic types, zero values, conversions, constants and iota, plus the fmt.Printf verbs you’ll use to look at all of them. Every program below was run on Go 1.26, and its output is pasted from the run.
Declaring variables: var and :=
Go has two ways to declare a variable. var names the variable and optionally its type and value. := declares and assigns in one step, and takes the type from the value.
package main
import "fmt"
var greeting = "hello"
func main() {
var count int
var name string = "Ada"
age := 36
fmt.Println(greeting, count, name, age)
}
It prints:
hello 0 Ada 36
count was declared with a type and no value, so it holds 0. name has both a type and a value. age := 36 gets the type int from the literal 36.
The two forms aren’t allowed in the same places:
varworks anywhere, including at package level, outside any function.greetingis declared that way.:=works only inside a function. Writecount := 3at package level and the build fails withsyntax error: non-declaration statement outside function body.
Inside a function, most Go code uses :=. Reach for var when you want the zero value on purpose, or when you want a type other than the one the literal would give you, as in var ratio float64 = 2.
:= needs at least one new name
The := form declares. It can’t be used to assign to a variable that already exists in the same scope:
package main
import "fmt"
func main() {
x := 1
x := 2
fmt.Println(x)
}
The build fails with:
./main.go:7:4: no new variables on left side of :=
Use x = 2 to change a variable you already have.
There is one exception, and you’ll see it constantly. When := has several names on the left, it’s allowed as long as at least one of them is new. The others are just assigned:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
fmt.Println(n, err)
m, err := strconv.Atoi("x7")
fmt.Println(m, err)
}
It prints:
42 <nil>
0 strconv.Atoi: parsing "x7": invalid syntax
The second line declares m and reuses err. That’s why Go code can call one function after another and keep checking the same err variable. The part on errors covers what to do with it.
The basic types
Most Go code gets by with four basic types:
| Type | Holds | Example |
|---|---|---|
int |
whole numbers | 42 |
float64 |
numbers with a fractional part | 2.99 |
string |
text | "Ada" |
bool |
true or false |
true |
int is 64 bits on the 64-bit machines you’ll almost always run on. When you need an exact size, for a file format, a network protocol or to save memory in a huge slice, Go has sized integers:
- signed:
int8,int16,int32,int64 - unsigned:
uint8,uint16,uint32,uint64, plusuint
An int8 holds -128 to 127. A uint8 holds 0 to 255. There’s also float32, but float64 is the default for decimals, and it’s what a literal like 2.99 becomes.
Overflow: wraps at run time, fails at compile time
A sized integer has room for a fixed range of values, and what happens when you go past the end depends on whether Go can see it coming. At run time, the value wraps around:
package main
import "fmt"
func main() {
var small int8 = 127
small++
fmt.Println(small)
var u uint8 = 0
u--
fmt.Println(u)
}
It prints:
-128
255
Adding 1 to the largest int8 gives the smallest one. Subtracting 1 from a uint8 holding 0 gives 255. There’s no panic and no warning. The bits roll over like a car’s odometer.
When the overflowing value is a constant written in your code, the compiler can check it, and it does:
package main
import "fmt"
func main() {
var small int8 = 128
fmt.Println(small)
}
The build fails with:
./main.go:6:19: cannot use 128 (untyped int constant) as int8 value in variable declaration (overflows)
So the rule is: constants are checked when you build, and arithmetic on variables wraps silently when you run. If a counter might really get that large, give it a bigger type.
Zero values: nothing is ever uninitialised
Every type in Go has a zero value, and a variable declared without a value holds that zero value. There is no “uninitialised” state to forget about:
package main
import "fmt"
func main() {
var i int
var f float64
var s string
var b bool
var p *int
var nums []int
var ages map[string]int
var point struct{ X, Y int }
fmt.Println(i, f, b, p, nums, ages, point)
fmt.Printf("%q\n", s)
fmt.Println(nums == nil, ages == nil, p == nil)
}
It prints:
0 0 false <nil> [] map[] {0 0}
""
true true true
Numbers start at 0. A bool starts as false. A string starts as "", the empty string, which is why it’s printed with %q here: Println would show nothing at all. Pointers, slices and maps start as nil. A struct starts with every field set to its own zero value, so point is {0 0}.
Explain it like I’m ten
Imagine every new box you get already has something inside. A box for numbers comes with a 0 in it. A box for yes-or-no answers comes with a “no”. A box for words comes with a blank card.
You never open a new box and find old junk someone else left behind. So even if you forget to put something in, you know exactly what’s there.
The precise version
When Go allocates memory for a variable, whether with var, new, make or a composite literal that leaves fields out, it sets that memory to zero bits. For each type, all zero bits means something sensible: 0 for numbers, false for bool, "" for strings, and nil for pointers, slices, maps, channels, functions and interfaces.
That’s why a Go program can’t read the leftover bytes of some earlier value, and why go vet doesn’t need to warn about using a variable before it’s set. It also shapes how Go types are designed. A good type is useful at its zero value: a nil slice is a working empty slice, and var total int is ready to add to.
Where the analogy breaks: some boxes are delivered empty and sealed. A nil map is fine to read, and reading any key gives the zero value, but writing to it panics. The part on maps shows why and what to do about it.
No implicit conversions
Go never converts between numeric types on its own, not even from int to float64 where nothing could be lost:
package main
import "fmt"
func main() {
count := 3
price := 2.5
fmt.Println(count * price)
}
The build fails with:
./main.go:8:14: invalid operation: count * price (mismatched types int and float64)
count is an int and price is a float64, and an operator needs both sides to have the same type. You say which conversion you want by writing the type name like a function call:
package main
import (
"fmt"
"math"
)
func main() {
count := 3
price := 2.99
total := float64(count) * price
fmt.Println(total)
fmt.Println(int(price), int(-price))
fmt.Println(int(math.Round(price)))
big := 300
fmt.Println(uint8(big))
}
It prints:
8.97
2 -2
3
44
float64(count) turns 3 into 3.0, so the multiplication works.
Going the other way, int(price) truncates. It drops the fractional part, so 2.99 becomes 2 and -2.99 becomes -2. It rounds toward zero, not down and not to the nearest. If you want rounding, call math.Round first.
The last line converts an int holding 300 to a uint8. Only the low 8 bits survive, and 300 minus 256 is 44. As with overflow, the compiler catches it when the value is a constant: uint8(300) fails with constant 300 overflows uint8. With a variable, it can’t know, so the value wraps.
The strictness costs a few extra words. What you get back is that every conversion in a Go program is written down, so you can find the place where a value lost precision.
Constants: typed and untyped
A constant is a value fixed when you build, declared with const. The surprising part is that a constant without a type can be used with several types:
package main
import "fmt"
const ratio = 2
func main() {
var count int = 5
var price float64 = 1.25
var tiny int8 = 3
fmt.Println(count*ratio, price*ratio, tiny*ratio)
fmt.Printf("%T %T %T\n", count*ratio, price*ratio, tiny*ratio)
}
It prints:
10 2.5 6
int float64 int8
The same ratio multiplied an int, a float64 and an int8, with no conversions. Compare that with the count * price error above. A variable holding 2 couldn’t do this.
Give the constant a type, and the flexibility goes away:
package main
import "fmt"
const ratio int = 2
func main() {
var price float64 = 1.25
fmt.Println(price * ratio)
}
The build fails with:
./main.go:9:14: invalid operation: price * ratio (mismatched types float64 and int)
ratio is now an int, and it follows the same rules as any int variable.
Explain it like I’m ten
An untyped constant is like the number 2 written on a sticky note. You can stick it on a jar of marbles, a jug of water or a bag of flour, and it means “two” of whatever is there.
A typed constant is the number 2 printed on a marble. It’s two marbles now, and you can’t pour it into the jug.
The precise version
An untyped constant has a kind, such as integer, floating-point or string, but no fixed type yet. When you use it, Go gives it the type the context needs, as long as the value fits that type. When nothing else decides, it takes a default type: int for 2, float64 for 2.5, string for "hi". That’s where age := 36 got int from.
Untyped constants are also exact. The compiler does constant arithmetic with far more precision than any Go type has, so an intermediate value can be huge as long as the final one fits:
package main
import "fmt"
const huge = 1 << 100
func main() {
fmt.Println(huge >> 98)
fmt.Println(huge / (1 << 90))
}
It prints:
4
1024
1 << 100 doesn’t fit in any integer type Go has. It’s fine as a constant, because only the results, 4 and 1024, ever become int values.
Where the analogy breaks: the sticky note still has to fit. The untyped constant 128 can’t go into an int8, which is the overflow error from earlier.
iota for enumerations
Go has no enum keyword. Instead, inside a const block, the name iota counts the lines: 0 on the first, 1 on the second, and so on. Combined with a named type, that gives you an enumeration:
package main
import "fmt"
type Weekday int
const (
Sunday Weekday = iota
Monday
Tuesday
Wednesday
)
func main() {
fmt.Println(Sunday, Monday, Tuesday, Wednesday)
fmt.Printf("%T %v\n", Tuesday, Tuesday)
}
It prints:
0 1 2 3
main.Weekday 2
Only Sunday has = iota written out. A constant with nothing after its name repeats the expression above it, so Monday is also Weekday = iota, but on the line where iota is 1. All four constants have the type Weekday, not plain int.
Skipping values
The blank name _ takes a line, and a value of iota, without creating a constant:
package main
import "fmt"
type Level int
const (
_ Level = iota
Debug
Info
_
Error
)
func main() {
fmt.Println(Debug, Info, Error)
var unset Level
fmt.Println(unset == Debug)
}
It prints:
1 2 4
false
The first _ uses up 0, and the second uses up 3. Skipping 0 is a common choice with a real reason behind it. The zero value of Level is 0, so a Level nobody set doesn’t accidentally mean Debug. unset == Debug is false.
Sizes and flags with 1 << iota
iota can appear inside any constant expression, and shifting is the classic use. Here each line shifts 1 left by ten more bits, so each size is 1024 times the one before:
package main
import "fmt"
type ByteSize int64
const (
_ = iota // 0: thrown away
KB ByteSize = 1 << (10 * iota)
MB
GB
TB
)
func main() {
fmt.Println(KB, MB, GB, TB)
file := 1536 * MB
fmt.Printf("%T\n", file)
fmt.Printf("%.2f GB\n", float64(file)/float64(GB))
}
It prints:
1024 1048576 1073741824 1099511627776
main.ByteSize
1.50 GB
KB is on the line where iota is 1, so it’s 1 << 10. MB repeats the expression with iota at 2, which is 1 << 20. 1536 * MB multiplies an untyped constant by a ByteSize, so the result is a ByteSize too.
Plain 1 << iota gives one bit per constant, which is how Go code writes flags you can combine:
package main
import "fmt"
type Permission uint8
const (
Read Permission = 1 << iota
Write
Execute
)
func main() {
fmt.Println(Read, Write, Execute)
perm := Read | Execute
fmt.Printf("%03b %d\n", perm, perm)
fmt.Println(perm&Write != 0, perm&Execute != 0)
}
It prints:
1 2 4
101 5
false true
Read | Execute sets two bits, and %03b prints them in binary. perm&Write != 0 asks whether one bit is set.
Looking at values with fmt.Printf
fmt.Printf takes a format string with verbs that start with %, and a handful of them cover nearly everything you’ll want to inspect:
package main
import "fmt"
func main() {
name := "Ada"
age := 36
height := 1.6549
admin := true
tags := []string{"go", "math"}
fmt.Printf("%v %v %v %v %v\n", name, age, height, admin, tags)
fmt.Printf("%T %T %T %T %T\n", name, age, height, admin, tags)
fmt.Printf("%q %q\n", name, tags)
fmt.Printf("%d|%5d|%-5d|\n", age, age, age)
fmt.Printf("%.2f %8.2f\n", height, height)
}
It prints:
Ada 36 1.6549 true [go math]
string int float64 bool []string
"Ada" ["go" "math"]
36| 36|36 |
1.65 1.65
%vprints any value in its default format. It’s whatPrintlnuses.%Tprints the type. It’s the quickest way to answer “what did:=give me?”%qprints strings with quotes, so empty strings and stray spaces become visible. It works on a slice of strings too.%dprints an integer. A number in the middle sets a minimum width, and-pads on the right instead of the left.%.2fprints a float with two decimal places, rounded.%8.2falso pads to 8 characters.
For structs, %v has two bigger siblings:
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
p := Point{3, 4}
fmt.Printf("%v\n", p)
fmt.Printf("%+v\n", p)
fmt.Printf("%#v\n", p)
}
It prints:
{3 4}
{X:3 Y:4}
main.Point{X:3, Y:4}
%+v adds field names, which makes debug output readable. %#v prints the value as Go syntax, type name and all.
Using the wrong verb doesn’t stop the program. fmt.Printf("%d\n", "Ada") prints %!d(string=Ada) and carries on. But go vet catches it before you run anything: fmt.Printf format %d has arg "Ada" of wrong type string. Running go vet on every change is worth the habit.
byte and rune are other names for integers
Go has two type names you’ll meet as soon as you work with text, and neither is a new type. byte is another name for uint8, and rune is another name for int32:
package main
import "fmt"
func main() {
var b byte = 'A'
var r rune = 'é'
fmt.Println(b, r)
fmt.Printf("%T %T\n", b, r)
fmt.Printf("%c %c %q\n", b, r, r)
var u uint8 = b
var i int32 = r
fmt.Println(u, i)
fmt.Println(len("café"))
}
It prints:
65 233
uint8 int32
A é 'é'
65 233
5
Println shows numbers, because that’s all they are. %T doesn’t even say byte or rune: it reports uint8 and int32, because an alias is the same type under a second name. That’s also why var u uint8 = b needs no conversion. To see the character, use %c, or %q for a quoted one.
The last line is a hint of what’s coming. "café" has four characters but a length of 5, because len counts bytes and é takes two. The part on strings, bytes and runes explains why.
What to remember
varworks anywhere and gives you the zero value when you leave out the value.:=works only inside functions, and needs at least one new name on the left.- Every type has a zero value: 0,
false,""ornil. A Go variable is never uninitialised. - Sized integers wrap around silently at run time. A constant that doesn’t fit is a compile error.
- Go never converts numbers for you. Write
float64(n)orint(f), and remember thatint(f)truncates toward zero. - Untyped constants take whatever type the context needs, as long as the value fits. Typed constants behave like variables of that type.
iotacounts the lines of aconstblock. Use_to skip values, often 0, and1 << iotafor flags.%v,%Tand%qare the fastest way to see what a value really is.
In Go, every value has a type you can print, and a starting value you can predict.