Hello Go, Variables, Control Flow & Functions
The foundation everything else is built on. If you've never written a line of Go before, start here. Every claim on this page was checked against a real Go 1.26 toolchain before being written down.
Hello, Go
Here's a complete, runnable Go program — every line matters. Click each highlighted part to see what it does.
package main import "fmt" func main() { fmt.Println("Hello, Go!") }
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}
With Go installed, save this as hello.go inside any directory and run
go run hello.go. The first time you run a Go file outside an existing
module, you'll see a compile error ("could not determine module path"). Fix this by
creating a module first:
go mod init hello creates a go.mod file that marks this
directory as a Go module — a self-contained unit of code. Every Go project
starts with go mod init, even if you only have one file. No Go installed
yet? Every "Open in Go Playground" link on this site runs the real Go compiler in
your browser, no install required.
package main
import (
"fmt"
"strings"
"time"
)
func main() {
fmt.Println(strings.ToUpper("hello"))
fmt.Println(time.Now().Weekday())
}
The parenthesized import syntax is the standard way to import several
packages at once. By convention, standard-library packages are grouped together, then
a blank line separates them from third-party imports — go fmt (Go's
built-in code formatter) sorts and groups them automatically. The compiler rejects unused
imports, so you'll never end up with dead imports cluttering your code.
In the code above, strings.ToUpper starts with a capital T —
and that's what makes it usable from outside the strings package. In Go,
a name is exported (public) if it starts with a capital letter and
unexported (private) if it starts with a lowercase letter. This is
the only visibility mechanism Go has — there are no
public or private keywords.
package main
import "fmt"
func main() {
fmt.println("hello") // lowercase p -> not exported!
}
The compiler catches this at compile time — it's not a runtime error. Every package's public API is defined purely by capitalization, which makes it visible without needing to look up documentation: if it starts with a capital letter, it's meant to be used from outside the package.
Variables & types
Go is statically typed: every variable has a type, and that type is fixed for its
lifetime. You can write the type explicitly with var, or let Go infer
it from the value using := (only inside functions — package-level
variables always need var).
package main
import "fmt"
func main() {
var age int = 30
var price float64 = 19.99
name := "Gopher"
var isReady bool
fmt.Println(age, price, name, isReady)
fmt.Printf("%T %T %T %T\n", age, price, name, isReady)
var count int
var label string
var total float64
var active bool
fmt.Println(count, label, total, active)
}
Look closely at that last line: 0 0 false — there are two spaces
between the first two values. That's not a typo in this page, it's genuinely what
Go prints: label's zero value is an empty string, and
fmt.Println puts one space between every argument, so an empty string
between two spaces just looks like a gap.
var isReady bool declares isReady with no value given
— and Go does not leave it as undefined memory. Every type has a
zero value it's automatically set to: 0 for numbers,
"" for strings, false for booleans. There's no
equivalent of reading an uninitialized variable and getting garbage.
Constants
A const declares a value that cannot change during program execution —
the compiler enforces this. Unlike var, a const must be given
a value on the same line; its type is inferred from the expression. Constants in Go are
untyped until they're used in a typed context, which lets them work
across different numeric types without conversion.
package main
import "fmt"
const Greeting = "Hello"
const (
StatusOK = 200
Status404 = 404
)
func main() {
fmt.Println(Greeting, StatusOK)
// Greeting = "Hi" // compile error: cannot assign to Greeting
}
Grouped const ( ... ) blocks are the standard way to define related
constants. Because constants are untyped until used, StatusOK can be
assigned to any integer type without an explicit conversion — something that's
not true of a regular var.
Type conversion
Go does not automatically convert between types, even if the
conversion is safe. If you have a float64 and need an int,
you must write the conversion explicitly. This is intentional — implicit
conversions have been a source of bugs in C, Java, and JavaScript for decades.
package main
import (
"fmt"
"math"
)
func main() {
x := 3.14 // float64 by default
y := int(x) // explicit float64 -> int (truncates)
z := int(math.Round(x)) // properly rounded
n := float64(42) // int -> float64
s := string(rune(65)) // rune -> string
fmt.Println(x, y, z, n, s)
}
In languages like C or Java, a "cast" can reinterpret the same bits as a different
type. Go's T(x) syntax always performs a real conversion,
either changing the bits or failing at compile time. There's no way to re-interpret
arbitrary memory as a different type — unsafe.Pointer exists but
is deliberately hard to use and almost never needed.
Multiple assignment
Go allows assigning to several variables at once, which pairs naturally with its multiple-return-value functions (shown in the Functions section further down). It's also a convenient way to swap values without a temporary variable.
package main
import "fmt"
func main() {
a, b, c := 1, "hello", true
fmt.Println(a, b, c)
// Swap without a temporary variable
x, y := 10, 20
x, y = y, x
fmt.Println("after swap:", x, y)
// Only use what you need
_, second := 1, 2
fmt.Println("second:", second)
}
The blank identifier _ discards a value you don't need
— here we skip the first return value. This is used constantly in Go for ignoring
unused function results.
The short declaration := creates a new variable. If you
use it inside an inner scope (inside an if block, for example) with a
name that already exists in an outer scope, you get a second, separate variable that
shadows the outer one — the outer variable is never modified, even
though it looks like you're reassigning it.
The fix is straightforward: use = (not :=) when you intend
to reassign an existing variable. := always declares something new;
= always assigns to something that already exists. Go even provides
go vet with a -shadow flag to detect accidental shadowing.
Control flow
Go keeps this deliberately small: if/else,
switch, and exactly one looping keyword, for, which covers
what other languages split into for, while, and
infinite-loop forms. Conditions never need parentheses, and braces are mandatory
— there's no single-statement shortcut.
package main
import "fmt"
func main() {
for i := 1; i <= 5; i++ {
if i%2 == 0 {
fmt.Println(i, "even")
} else {
fmt.Println(i, "odd")
}
}
day := 3
switch day {
case 1, 7:
fmt.Println("weekend")
default:
fmt.Println("weekday")
}
n := 0
for n < 3 {
fmt.Println("n is", n)
n++
}
}
Three things worth noticing: switch in Go does not
fall through to the next case by default (unlike C or JavaScript) — each case
breaks automatically. A for with just a condition (for n < 3)
is Go's version of a while loop. And for with nothing at
all (for { ... }) is Go's infinite loop, typically exited with
break or return.
If you read older Go tutorials or Stack Overflow answers, you'll likely see a
famous warning: launching a goroutine inside a for loop that
captures the loop variable (e.g. go func() { use(i) }()) used to be a
common bug, because every iteration shared the same i, so
the goroutines would typically all see its final value once the loop ended.
This was fixed in Go 1.22: each iteration now gets its own fresh
copy of the loop variable. We confirmed this ourselves — running a loop that
launches three goroutines each capturing i directly, with no
workaround, correctly and consistently prints [0 10 20] on this
site's Go 1.26 toolchain, never a jumbled or repeated result. One catch: this
depends on your module's declared Go version, not just your installed toolchain
— a go.mod that still declares go 1.21 or earlier
keeps the old sharing behavior even when built with a Go 1.22+ toolchain. Check
(or bump) the go directive in go.mod if you're relying
on this.
For-range: iterating over collections
The for range form iterates over elements of a slice, map, string, or
channel. What it yields as the two iteration values changes depending on the type
you're ranging over — a common point of confusion for newcomers.
range yields two values — check what changes per type.package main
import "fmt"
func main() {
slice := []string{"a", "b", "c"}
for i, v := range slice {
fmt.Println(i, v)
}
letters := map[string]int{"x": 1, "y": 2}
for k, v := range letters {
fmt.Println(k, v) // order is NOT guaranteed!
}
text := "Hi"
for pos, runeVal := range text {
fmt.Printf("byte %d: %c\n", pos, runeVal)
}
}
Notice the pattern changes per type: slices and arrays give you index, element,
maps give you key, value, and strings give you byte offset, rune
(Unicode code point). If you only need the element or key and can skip the index, use
for _, v := range ... with the blank identifier.
If with initialization
Go allows a short statement before the condition in an if. This is most
commonly used for the "check, then handle" pattern that is everywhere in Go code:
package main
import (
"fmt"
"strconv"
)
func main() {
input := "42"
if n, err := strconv.Atoi(input); err != nil {
fmt.Println("not a number:", err)
} else {
fmt.Println("parsed:", n)
}
}
n and err are scoped to the if/else block —
they don't leak into the surrounding function. This is the most idiomatic Go error-handling
pattern, and you'll see it in virtually every function that can fail.
Tagless switch
A switch with no value after the keyword evaluates each case's boolean
expression and picks the first true one. This is Go's idiomatic replacement for long
if/else if chains:
package main
import "fmt"
func classify(x int) string {
switch {
case x < 0:
return "negative"
case x == 0:
return "zero"
case x < 10:
return "small"
default:
return "large"
}
}
func main() {
for _, v := range []int{-5, 0, 3, 42} {
fmt.Println(v, classify(v))
}
}
Each case is checked top-to-bottom, and the first match wins — no break
needed. The default case matches if nothing else does, like a final else.
A plain break inside a nested loop only exits the innermost loop. To
break out of an outer loop, label it:
package main
import "fmt"
func main() {
outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i == 1 && j == 1 {
break outer // exits BOTH loops
}
fmt.Println(i, j)
}
}
fmt.Println("done")
}
Without the outer label, break would only exit the inner
j loop and continue the i loop. Labels work with
continue too for skipping to the next iteration of a named loop.
break exits the loop immediately; continue skips the rest
of the current iteration and moves to the next one. Try them in the loop visual above
and watch how the remaining iterations change.
Functions
Functions can take any number of typed parameters, and — distinctively for a mainstream language — return more than one value natively, with no wrapper object or out-parameter required.
package main
import "fmt"
func divmod(a, b int) (int, int) {
return a / b, a % b
}
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func namedReturn(a, b int) (sum int, diff int) {
sum = a + b
diff = a - b
return
}
func main() {
q, r := divmod(17, 5)
fmt.Println(q, r)
fmt.Println(sum(1, 2, 3, 4))
s, d := namedReturn(10, 4)
fmt.Println(s, d)
}
sum(nums ...int) is a variadic parameter — call
it with any number of int arguments (sum(1, 2, 3, 4)), and
inside the function nums is just a slice. namedReturn
shows named return values: declaring sum and diff in the
signature itself means a bare return with no arguments sends back
whatever they currently hold.
The (result, error) pattern
In Go, errors are returned values — not exceptions. The convention is to return an
extra error as the last return value, and have the caller check it
immediately. This is the single most common pattern in all of Go code:
package main
import (
"fmt"
"strconv"
)
func parseAndAdd(a, b string) (int, error) {
x, err := strconv.Atoi(a)
if err != nil {
return 0, fmt.Errorf("parse a: %w", err)
}
y, err := strconv.Atoi(b)
if err != nil {
return 0, fmt.Errorf("parse b: %w", err)
}
return x + y, nil
}
func main() {
sum, err := parseAndAdd("10", "32")
fmt.Println(sum, err)
sum, err = parseAndAdd("10", "abc")
fmt.Println(sum, err)
}
The idiomatic Go pattern is result, err := doSomething(), then
if err != nil { return ... } on the next line. When there's no error,
you return the zero value for the other results (here, 0), but you could
also return partial results with a wrapped error. Core.html has more on error wrapping
and errors.Is.
Functions as values
In Go, functions are first-class values — you can store them in variables, pass them as arguments, and return them from other functions. This is the foundation of Go's approach to callbacks, middleware, and event handling:
package main
import "fmt"
func apply(f func(int) int, n int) int {
return f(n)
}
func main() {
double := func(x int) int { return x * 2 }
square := func(x int) int { return x * x }
fmt.Println(apply(double, 5))
fmt.Println(apply(square, 5))
}
A func(x int) int is a function literal — an
anonymous function value that you can assign to a variable. This is used constantly
in HTTP handlers, goroutines, and as callbacks to library functions like
sort.Slice.
Closures: functions that capture their environment
When a function literal references a variable defined outside its body, the function doesn't get a copy — it holds a reference to the original variable. This means the function can still read and modify that variable even after the surrounding function has returned. This is called a closure.
package main
import "fmt"
func makeTripler() func(int) int {
multiplier := 3
return func(x int) int {
return multiplier * x
}
}
func main() {
triple := makeTripler()
fmt.Println(triple(7))
fmt.Println(triple(3))
}
makeTripler() runs, creates multiplier with value 3, then
returns a function that closes over it. The returned function "remembers"
multiplier is 3 long after makeTripler() has returned and
the variable would normally be out of scope. This is the foundation of Go's
middleware pattern and goroutine-friendly callbacks.
defer: guaranteed cleanup
A defer statement schedules a function call to run after the
current function returns. Multiple defer calls run in
reverse order (last-in, first-out) — like a stack. This is the
standard way to ensure resources (files, locks, network connections) are always cleaned
up, even if the function panics.
defer onto the stack.package main
import "fmt"
func main() {
defer fmt.Println("cleanup file")
defer fmt.Println("release lock")
defer fmt.Println("close connection")
fmt.Println("main work")
}
Notice the output order: "main work" prints first, then the defers run in
reverse order — "close connection" (the last defer pushed) runs first,
then "release lock", then "cleanup file". The practical rule:
defer cleanup in the order you'd naturally clean up (outermost last),
because LIFO will execute it in reverse — first-in, last-out — which is
typically the correct cleanup order for nested resources.
The function call is deferred, but its arguments are evaluated right away. In the
code above, fmt.Println("close connection") captures the string
now — the defer only affects when the function runs, not when
its inputs are evaluated. This matters when you defer a loop body: the arguments
are evaluated once per iteration, which is almost always the correct behavior.
Interview Questions
Common interview questions about Go basics — syntax, variables, control flow, and functions — organized by experience level. Click a tab to filter.
Q1. What must every executable Go program have, and what does func main do?
Every executable Go program must belong to package main and define a func main() with no arguments and no return value. main is the entry point — execution starts there once the runtime has initialized package-level variables and run any init functions.
Q2. What is the difference between declaring a variable with var and with :=?
var x int = 5 is an explicit declaration that can appear at package or function scope and works even with no initial value (it gets the zero value). x := 5 is short variable declaration — it infers the type from the right-hand side and can only be used inside a function body, never at package scope.
Q3. What is a "zero value" in Go? Give an example.
A zero value is the default value a variable gets when it's declared without an explicit initializer. For example, var n int is 0, var s string is "", var b bool is false, and var p *int is nil. Go has no concept of an uninitialized variable.
Q4. Name some of Go's basic scalar types.
int and float64 for numbers, string for text, bool for true/false, byte (an alias for uint8) for raw bytes, and rune (an alias for int32) for a single Unicode code point.
Q5. What is the difference between const and var?
const declares a value that is fixed at compile time and can never be reassigned; var declares a variable whose value can change at runtime. Constants can only be numbers, strings, characters, or booleans — you cannot declare a const slice, map, or struct.
Q6. Does Go require parentheses around if and for conditions?
No — Go does not use parentheses around conditions, for example if x > 0 { ... } not if (x > 0). Braces around the body, however, are mandatory, even for a single statement.
Q7. What forms can Go's for loop take?
Go has a single looping keyword, for, with four forms: the classic three-clause for i := 0; i < n; i++, a condition-only form that acts like a while loop, a bare for {} for an infinite loop, and for i, v := range collection to iterate over arrays, slices, strings, maps, or channels.
Q8. Does a Go switch fall through to the next case by default?
No. Unlike C or Java, each case in a Go switch breaks automatically after its statements run. You must use the explicit fallthrough keyword if you want execution to continue into the next case.
Q9. Can a Go function return more than one value? Give a common use case.
Yes — Go functions can return multiple values, declared in the signature like func divide(a, b int) (int, error). The most common use is returning a result alongside an error, which is Go's idiomatic replacement for exceptions.
Q1. Why does x := 5 followed by another x := 10 in the same scope fail to compile, but x, y := 5, 6 work after x already exists?
:= requires at least one new variable on the left-hand side in the current scope; if all variables already exist, it's a plain redeclaration and the compiler rejects it as unused syntax. When mixed with a new variable like y, Go treats the already-declared x as a plain assignment and only declares y — this only applies within the same block scope, not across nested scopes.
Q2. When are a deferred function's arguments evaluated — at the defer statement or when the function actually runs?
Arguments to a deferred call are evaluated immediately, at the point the defer statement executes — only the actual call is postponed until the surrounding function returns. So for i := 0; i < 3; i++ { defer fmt.Println(i) } prints 2 1 0, each with the value i had at the time of that iteration's defer, not at the end of the loop.
Q3. What's the gotcha with writing if err := doWork(); err != nil { ... } when there's already an err in the outer scope?
The if statement's initializer introduces a new scope, so err := doWork() declares a fresh, shadowed err local to that if block rather than reusing the outer one. Code after the if block that reads the outer err won't see this new value, which is a common source of "why didn't my error get set" bugs.
Q4. What is a named return value, and what's a common gotcha with it?
A named return, like func f() (result int), pre-declares the return variable so a bare return statement sends back its current value. The gotcha is that using := inside a nested block (like an if) to compute the result creates a new shadowed variable instead of assigning the named return, so a bare return afterward sends back the zero value instead of what you computed.
Q5. How does iota work inside a const block?
iota starts at 0 and increments by one for each ConstSpec line within a single const (...) block, then resets to 0 in the next block. It's commonly used to build simple enumerations, such as const ( Sun = iota; Mon; Tue ), without writing out each value.
Q6. What's the difference between ranging over a string byte-by-byte versus with range?
for i, r := range s decodes the string as UTF-8 and yields the byte index where each rune starts along with its decoded rune value, so multi-byte characters are handled correctly and the index can skip values. Indexing the string directly with s[i] instead gives you the raw byte at that position, which can split a multi-byte character.
Q7. How do you pass an existing slice into a variadic function's ...T parameter?
You append ... after the slice at the call site, e.g. nums := []int{1,2,3}; sum(nums...), which passes the slice directly instead of wrapping it in another one. Without the trailing ..., a slice can't be passed where individual variadic arguments are expected — it's a compile error.
Q8. Can a switch case match multiple values, and how does explicit fallthrough work?
Yes — a case can list several comma-separated values, like case "a", "b":, and it matches if any of them equal the switch expression. fallthrough as the last statement in a case unconditionally executes the next case's body regardless of whether its own condition matches.
Q9. What's the difference between a typed and an untyped constant?
An untyped constant, like const Pi = 3.14159, has no fixed type and implicitly converts to whatever numeric type it's used with, as long as the value fits. A typed constant, like const Pi float32 = 3.14159, is locked to that type and requires explicit conversion to use with another numeric type.
Q1. Go 1.22 changed how for loop variables work — what changed, and why did it matter?
Before Go 1.22, a for loop's index and range variables were single variables reused across every iteration; since 1.22, each iteration gets its own fresh copy. This mattered because a closure created inside the loop body — for example one launched as a goroutine or stored for later — used to capture the shared variable and could observe the loop's final value instead of the value at the iteration it was created for, a very common bug source that the language change eliminated at the source.
Q2. What are "open-coded defers" and when do they not apply?
Since Go 1.14, the compiler can inline the execution of defer calls directly at the return points of a function instead of pushing a defer record onto a runtime-managed list, avoiding that allocation and bookkeeping overhead. This optimization only applies to functions with a bounded number of defers (at most 8) that aren't inside a loop; a defer called in a loop body always falls back to the slower, heap-tracked defer path since the compiler can't know the count ahead of time.
Q3. Can a deferred function modify a named return value after return has already executed?
Yes — a return statement in a function with named results first assigns those results, then runs deferred calls, and only then actually returns to the caller. A deferred function that references the named return variable can therefore still change it, which is exactly how the common defer func() { if r := recover(); r != nil { err = fmt.Errorf(...) } }() pattern converts a panic into an error result.
Q4. How can iota be used for bit-flag constants or with skipped values?
iota can be used in arbitrary expressions, so const ( _ = iota; KB = 1 << (10 * iota); MB; GB ) skips the first value with _ and produces power-of-two byte sizes by shifting per iteration. The same trick, 1 << iota, is the standard idiom for generating a set of distinct bit-flag constants (1, 2, 4, 8, ...) that can be combined with bitwise OR.
Q5. When you range over a slice of structs, does modifying the loop variable change the underlying slice?
No — for _, v := range structs copies each element by value into v, so mutating fields on v has no effect on the original slice. To mutate in place you need to index the slice directly, structs[i].Field = ..., or range over a slice of pointers.
Q6. What's the tradeoff in choosing named returns over ordinary returns?
Named returns can improve readability at the call-signature level (documenting what each return value means) and are required if a deferred function needs to modify the result before it's returned to the caller. The tradeoff is the shadowing risk when a nested block reassigns the name with := instead of =, silently discarding the intended update — for short functions, explicit return x, err is usually clearer.
Q7. Why can an untyped constant expression represent a value that would overflow any of Go's runtime numeric types?
Untyped constants are evaluated by the compiler with arbitrary precision arithmetic, not with the fixed bit width of a concrete type, so intermediate expressions can temporarily exceed what any real numeric type could hold. The value only has to fit once it's actually assigned to a typed variable or used in a typed context — if it doesn't fit at that point, it's a compile-time error, not a runtime overflow.
Q8. What's the aliasing risk when passing a slice into a variadic parameter with ...?
Passing nums... hands the callee a slice header that points at the same underlying array as the caller's slice, it is not copied. If the variadic function writes to elements of its parameter slice (rather than appending beyond its length), those writes are visible back in the caller's original slice.
Q9. How does a tagless switch { case cond1: ...; case cond2: ... } behave compared to switch true?
A switch with no tag expression is exactly equivalent to switch true — each case holds a boolean expression, evaluated top-to-bottom, and the first one that evaluates to true runs. It's a common, more readable idiom for chains of conditions that would otherwise be a long if / else if ladder.