🚧 We are still building the lighthouse. Hang tight!

projectlighthouse logoprojectlighthouse

Sign in with your Google or GitHub account to get started

Values, Variables, and Constants

Master Go's type system, variable declaration, constants, and zero values. Understand how Go stores data in memory and the implications for your code.

Programs manipulate data. You read files, parse JSON, calculate results, format output. Behind every operation is memory—bytes that represent numbers, text, booleans, or more complex structures. Go's type system is how you tell the compiler what those bytes mean.

Some other languages, specially interpreter languages hide this. Python lets you write x = 5 without declaring types. The interpreter figures it out at runtime. This flexibility has costs—type errors surface during execution, not compilation, and the runtime must track type information for every value. Go takes a different approach: types are explicit, checked at compile time, and don't exist at runtime.

Basic Types

Go provides types for common data:

package main

import "fmt"

func main() {
    var age int = 32
    var temperature float64 = 98.6
    var isOnline bool = true
    var username string = "alice"

    fmt.Println(age, temperature, isOnline, username)
}

Each variable has a type that determines its size in memory and what operations work on it. An int stores whole numbers, a float64 stores decimal numbers, a bool stores true or false, and a string stores text.

Run this and you get:

32 98.6 true alice

The types here aren't arbitrary. Go offers precise control over memory layout through sized integer types:

var a int8    // -128 to 127 (1 byte)
var b int16   // -32768 to 32767 (2 bytes)
var c int32   // -2 billion to 2 billion (4 bytes)
var d int64   // very large range (8 bytes)

var e uint8   // 0 to 255 (1 byte, unsigned)
var f uint16  // 0 to 65535 (2 bytes, unsigned)
var g uint32  // 0 to 4 billion (4 bytes, unsigned)
var h uint64  // 0 to very large (8 bytes, unsigned)

The int type adapts to your architecture—32 bits on 32-bit systems, 64 bits on 64-bit systems. For most code, use int unless you specifically need a fixed size (for binary protocols, file formats, or memory-constrained situations).

Floating-point types come in two sizes:

var x float32  // single precision (4 bytes)
var y float64  // double precision (8 bytes)

Use float64 by default. The precision difference matters for scientific computing, graphics, or cases where rounding errors accumulate.

Memory Layout of Types

Types determine how values are stored in memory. Understanding memory layout explains why types matter and how Go achieves performance without sacrificing safety.

Consider these variables:

var a int8 = 42
var b int32 = 100000
var c bool = true
var d float64 = 3.14

Memory layout:

Memory Address       Variable     Type      Value (Binary)              Bytes
┌─────────────────────────────────────────────────────────────────────────────┐
│ 0x1000             a            int8      00101010                    1     │
│ 0x1001             (padding)                                          3     │
│ 0x1004             b            int32     00000001 10000110           4     │
│                                           10100000 00000000                 │
│ 0x1008             c            bool      00000001                    1     │
│ 0x1009             (padding)                                          7     │
│ 0x1010             d            float64    01000000 00001001           8     │
│                                           00011110 10111000                 │
│                                           01010001 11101100                 │
│                                           10001111 01011000                 │
└─────────────────────────────────────────────────────────────────────────────┘

Each type occupies a specific number of bytes. int8 uses 1 byte, int32 uses 4 bytes, bool uses 1 byte, float64 uses 8 bytes. The compiler adds padding to align values on word boundaries for efficient CPU access.

Stack vs Heap:

Local variables typically live on the stack:

┌─────────────────────────────┐
│         Stack               │
├─────────────────────────────┤
│                             │
│  main()                     │
│    x: 42        (int)       │
│    y: 3.14      (float64)    │
│    name: "Alice" (string)   │
│                             │
│  calculate()                │
│    temp: 100    (int)       │
│    result: 200  (int)       │
│                             │
└─────────────────────────────┘
      ↓ grows downward

The stack grows with each function call and shrinks when functions return. Stack allocation is fast—just adjust a pointer. Variables are automatically freed when functions exit.

Heap stores data that outlives function calls:

┌─────────────────────────────┐
│          Heap               │
├─────────────────────────────┤
│      ↑ grows upward         │
│                             │
│  Large struct               │
│  Slice backing array        │
│  Map data                   │
│  Data escaping to caller    │
│                             │
└─────────────────────────────┘

Go's compiler decides whether variables live on the stack or heap through escape analysis. If a variable's address is returned from a function or stored where it outlives the function, the compiler allocates it on the heap:

func createUser() *User {
    user := User{Name: "Alice"}
    return &user  // user escapes to heap
}

The user variable escapes because its address is returned. The compiler automatically heap-allocates it. The garbage collector reclaims it later. You don't manage this manually—the compiler handles it.

String Memory Layout:

Strings have special structure:

Variable: name = "Hello"

Stack:
┌──────────────────────────┐
│ name (string)            │
├──────────────────────────┤
│ ptr:  0x2000  ───────────│─┐
│ len:  5                  │ │
└──────────────────────────┘ │
                             │
Heap/Data Segment:           │
┌──────────────────────────┐ │
│ 0x2000: ['H']['e']['l'] │◄─┘
│         ['l']['o']       │
└──────────────────────────┘

A string variable holds two values: a pointer to the bytes and the length. The actual bytes live elsewhere (often in read-only data segments for string literals). This explains why len() is O(1)—the length is stored, not calculated.

Zero Value Memory:

Uninitialized variables get zeroed memory:

var x int
var y bool
var z string

Memory after declaration:

┌──────────────────────────┐
│ x: 0000 0000 0000 0000   │  (int64 on 64-bit system)
│ y: 0000 0000             │  (bool)
│ z: ptr: 0x0000           │  (nil pointer)
│    len: 0                │  (zero length)
└──────────────────────────┘

All bits are zero. This guarantee means variables are always in a valid state. No undefined behavior, no reading random memory contents.

Variable Declaration

Go offers several ways to declare variables. The verbose form specifies everything explicitly:

var count int = 10

If you initialize the variable, Go infers the type:

var count = 10  // type inferred as int

The short declaration syntax combines declaration and initialization:

count := 10  // equivalent to: var count int = 10

The := operator only works inside functions. At package level (outside any function), you must use var. This distinction exists because := creates new variables, and the compiler needs to distinguish between creating a new variable and assigning to an existing one.

You can declare multiple variables together:

var x, y int = 1, 2
a, b := 3, 4
var (
    name string = "alice"
    age int = 32
    active bool = true
)

The grouped declaration with var () is common for package-level variables. It keeps related declarations together and reduces repetition. However, I'd not personally recommend var x,y int = 1,2 approach.

Zero Values

Every variable gets initialized, even if you don't provide a value. Go doesn't have uninitialized memory:

var count int
var price float64
var active bool
var name string

fmt.Println(count)   // 0
fmt.Println(price)   // 0.0
fmt.Println(active)  // false
fmt.Println(name)    // "" (empty string)

Zero values are as follows

  1. Numeric types default to zero
  2. booleans to false, and
  3. strings to the empty string ("").
  4. Pointers defaults to nil
  5. Slices, maps also defaults to nil This zero value guarantee eliminates an entire class of bugs. You can't accidentally use uninitialized memory. We'll explore those in later articles, but the principle holds: every type has a sensible zero value.

Type Safety and Conversions

Go's type system is strict. You cannot mix types implicitly:

var a int32 = 10
var b int64 = 20
var c = a + b  // Compile error: invalid operation

This fails because int32 and int64 are different types, even though both represent integers. You must convert explicitly:

var a int32 = 10
var b int64 = 20
var c = int64(a) + b  // Works: a converted to int64

The conversion syntax looks like a function call but happens at compile time. Converting between numeric types is straightforward, but precision can be lost:

var x float64 = 3.14159
var y int = int(x)  // y is 3, fractional part discarded

Converting from larger integer types to smaller ones truncates bits:

var x int64 = 300
var y int8 = int8(x)  // y is 44 (300 doesn't fit in int8, wraps around)

This explicit conversion requirement prevents subtle bugs. When you see int64(x) in code, you know a type conversion is happening. Nothing is hidden.

Strings

Strings in Go are immutable sequences of bytes. The bytes represent UTF-8 encoded text:

var greeting string = "Hello, 世界"
fmt.Println(len(greeting))  // 13 (bytes, not characters)

The length is 13 bytes, not 9 characters, because "世界" takes multiple bytes per character in UTF-8. Strings are byte sequences—the encoding is UTF-8, but the string itself is just bytes.

You cannot modify strings:

var s string = "hello"
s[0] = 'H'  // Compile error: cannot assign to s[0]

To modify string content, convert to a byte slice, modify, then convert back:

s := "hello"
b := []byte(s)
b[0] = 'H'
s = string(b)
fmt.Println(s)  // Hello

String concatenation uses the + operator:

first := "John"
last := "Doe"
full := first + " " + last  // "John Doe"

Concatenating many strings allocates repeatedly. For building strings in loops, use strings.Builder:

import "strings"

var builder strings.Builder
for i := 0; i < 100; i++ {
    builder.WriteString("x")
}
result := builder.String()

This amortizes allocations instead of creating a new string on every iteration.

Constants

Constants are values known at compile time. They don't change:

const Pi = 3.14159
const MaxConnections = 100
const Greeting = "Hello, World!"

Constants can be untyped, meaning they don't have a fixed type until used:

const x = 42  // untyped constant

var a int = x      // x becomes int
var b float64 = x  // x becomes float64
var c int32 = x    // x becomes int32

Untyped constants are convenient for numeric literals and avoid explicit conversions. The value 42 can be any numeric type that holds it.

Typed constants have explicit types:

const x int = 42
var y int32 = x  // Compile error: cannot use x (type int) as type int32

Constants must be compile-time values. You can't make a constant from a function call or any runtime calculation:

const x = len("hello")  // Works: len of string literal is compile-time
const y = len(someVar)  // Error: someVar is runtime value

The iota Enumerator

Go provides iota for generating sequences of constants:

const (
    Sunday = iota     // 0
    Monday            // 1
    Tuesday           // 2
    Wednesday         // 3
    Thursday          // 4
    Friday            // 5
    Saturday          // 6
)

Each constant in the const block gets the next value. iota starts at 0 and increments.

You can use expressions with iota:

const (
    _  = iota             // ignore 0
    KB = 1 << (10 * iota) // 1 << 10 = 1024
    MB                    // 1 << 20 = 1048576
    GB                    // 1 << 30 = 1073741824
    TB                    // 1 << 40 = 1099511627776
)

The expression repeats for each constant, with iota incrementing. This generates powers of 1024 cleanly.

Using _ (blank identifier) discards the zero value when you want sequences starting at 1:

const (
    _ = iota  // skip 0
    StatusActive
    StatusPending
    StatusInactive
)

Now StatusActive is 1, StatusPending is 2, and StatusInactive is 3.

Type Declarations

You can create named types from existing types:

type UserID int64
type Temperature float64
type Status string

These aren't aliases—they're distinct types. A UserID and an int64 are different types even though they have the same underlying representation:

type UserID int64

var id UserID = 100
var x int64 = 200
var y = id + x  // Compile error: cannot add UserID and int64

You must convert:

var y = int64(id) + x  // Works

This type safety prevents mixing semantically different values. User IDs and timestamps are both int64, but they shouldn't be interchangeable. Named types make this explicit.

Type declarations are common for improving readability:

type Meters float64
type Seconds float64

func Speed(distance Meters, time Seconds) Meters {
    return distance / Meters(time)
}

Wait, that's wrong. Dividing meters by seconds gives meters per second, not meters:

type MetersPerSecond float64

func Speed(distance Meters, time Seconds) MetersPerSecond {
    return MetersPerSecond(distance / Meters(time))
}

The type system caught a conceptual error. When you make units explicit through types, the compiler helps enforce dimensional analysis.

Type Inference Boundaries

Go infers types from context, but inference has limits. When you use :=, the type comes from the right-hand side:

x := 42       // int
y := 3.14     // float64
z := "hello"  // string

Numeric literals without a decimal point are int, with a decimal point are float64. If you want a different type, convert explicitly:

var x int32 = 42
y := int32(42)

Type inference works across assignments too:

func getTemperature() float64 {
    return 98.6
}

temp := getTemperature()  // temp is float64

The function return type determines the variable type. This keeps code concise without sacrificing type safety.

Why Strong Typing Matters

Go's strict type system catches errors at compile time. Converting between types requires explicit intent. This verbosity prevents bugs:

Consider a function that takes milliseconds:

type Milliseconds int64

func Sleep(duration Milliseconds) {
    // ...
}

If you accidentally pass seconds, the compiler catches it:

var seconds int64 = 5
Sleep(seconds)  // Error: cannot use seconds (type int64) as type Milliseconds

You must convert explicitly, which makes the unit conversion visible:

Sleep(Milliseconds(seconds * 1000))

This explicitness prevents an entire category of bugs. The conversion is obvious in code review. Six months later, you see the multiplication by 1000 and understand the unit change.

Comparing to Other Languages

Python lets you write x = 5 without declaring types. The interpreter figures it out. This flexibility means you discover type errors when the code runs, not when it compiles.

Java requires type declarations but allows implicit conversions between numeric types. You can assign an int to a long without conversion. Go requires explicit conversion even between integer types.

C has implicit conversions that silently truncate or extend values. Go refuses to compile mixed-type operations. You see exactly where conversions happen.

Go's approach favors maintainability over convenience. Reading code reveals all type conversions explicitly. The compiler catches type mismatches before the code runs.

Where This Leads

Variables and types establish how programs store data. The next step is controlling program flow—making decisions with if statements and switch, and repeating operations with loops. Once you can make decisions and iterate, you can combine basic types into more complex structures like slices, maps, and structs.

Every Go program builds from these fundamentals: typed values in memory, explicit conversions, compile-time checking. The language prevents cleverness that obscures intent. This becomes an advantage as codebases grow—type safety scales better than flexibility when you're reading code written by others months or years ago.

Notes & Highlights

© 2025 projectlighthouse. All rights reserved.