Go fundamentals are the core rules that make a source file compile: packages, typed values, declarations, functions, and structured control flow.
:= can create a same-named variable in an inner scope, while len(string) counts bytes; both can leave compiling code with the wrong result.
Identify every name’s scope and type first. Use = to update an existing variable, and use range or unicode/utf8 when text must be processed as Unicode code points.
What it is and why it exists
Go is a strongly typed, garbage-collected, compiled language with direct support for concurrency. An executable Go program consists of packages; the main function in package main is its entry point. Go fundamentals are the smallest useful language model for reading and writing such programs, not a catalog of standard-library APIs.
The model has straightforward constraints: every value has a type, every name has a scope, and a small set of statements controls execution. The compiler rejects unused local variables and imports, assignments with incompatible types, and many ambiguous forms. That feedback is strict, but it removes a class of mechanical errors before the program runs.
You meet these rules in your first command-line program, test file, and service entry point. Slices, maps, structs, methods, interfaces, error handling, and goroutines all build on the same rules for declarations, expressions, functions, and scope, but each deserves separate treatment.
This topic covers source-file structure, basic types, variables and constants, functions, and if, switch, and for control flow. It does not expand on defer, panic, methods, or concurrency because sibling topics cover their individual contracts.
How it works
Files, packages, and the entry point
Every non-empty Go source file begins by declaring its package, such as package main. Files in one directory that take part in a build normally belong to the same package and jointly define its package-level names. The package clause can be followed by imports, then top-level declarations such as constants, variables, types, and functions.
An import path identifies another package, and code selects its exported names through the package name, as in fmt.Println. Whether an identifier begins with a Unicode uppercase letter determines whether another package can access it; Go has no public or private modifier here. An import is available only in its source file, even when another file in the package already imports the same package.
An executable uses package main and defines func main() with no parameters or results. A library package has no such entry point and is imported by other packages. A module controls import paths and dependency versions, while a package is a compilation and namespace unit; they are not the same concept.
Values always have types
A type determines a set of values and the operations available on them. Predeclared basic types include bool, string, signed and unsigned integers, floating-point numbers, and complex numbers. The width of int and uint is implementation-dependent, so protocols, file formats, and fixed layouts should use explicit widths such as int32 or uint64.
byte is an alias for uint8 and usually expresses a raw byte. A rune is an alias for int32 and usually expresses a Unicode code point. An alias does not declare a new type, so byte and uint8 are two names for one type, as are rune and int32.
A string is an immutable byte sequence. It commonly holds UTF-8 text, but the language permits arbitrary bytes. Indexing a string produces one byte; ranging over it decodes Unicode code points and reports the byte index where each code point starts. One user-perceived character can contain several code points, so a rune count is not necessarily a grapheme count.
Arrays, slices, maps, structs, pointers, functions, interfaces, and channels are composite types. They still follow the same rule: a variable can hold only values assignable to its static type. Interface values also have a runtime dynamic type, but that belongs in the interfaces topic.
Declarations, assignment, and scope
A variable is a storage location that holds a value. A declaration can state its type or let the compiler perform type inference . Inference determines a compile-time type; assigning a different kind of value later cannot change that type.
The common declaration forms differ as follows:
| Form | Type source | Initial value | Where it works |
|---|---|---|---|
var count int | Explicit int | The zero value of int | Package level or inside a function |
var count = 3 | Inferred from the initializer | 3 | Package level or inside a function |
count := 3 | Inferred from the right side | 3 | Inside a function only |
const limit = 3 | Remains untyped until context requires a type | Exact constant value 3 | Package level or inside a function |
A variable without an explicit initializer receives its type’s zero value . Numbers use 0, booleans use false, and strings use ""; the zero value of pointers, slices, maps, functions, interfaces, and channels is nil. A zero value is always valid, but whether a particular operation can safely use it still depends on the type.
The short variable declaration := declares and initializes names, and it works only inside a function body. In the same block, at least one non-blank name on the left must be new; the other names may receive new values. A same-named declaration in an inner if or for block creates another variable.
= assigns to variables that already exist; it does not declare names. Braces create explicit blocks, while function bodies and if, switch, and for constructs introduce their associated scopes. To decide whether a line updates existing state or creates new state, inspect both the operator and the block containing it.
Name lookup and shadowing
An identifier’s declaration determines the entity its name denotes. When code reads a name, the compiler searches outward from the innermost scope. An inner declaration can temporarily hide an outer declaration with the same name; this is shadowing. The declarations refer to two variables, and the outer variable and its old value remain after the inner block ends.
The common scopes have the nesting shown below. An arrow means that code in the inner scope can continue looking outward for a name that has not been shadowed.
A package-level name belongs to the package block and can be referenced from another file in that package. An imported package name belongs only to the file block containing that import, so another file cannot borrow it. Parameters, result parameters, and local variables belong to a function or a smaller inner block.
Scope also depends on where a declaration appears. Most local variables enter scope after their declaration ends, so a same-named reference on the right side of an initializer may still refer to an outer entity. This rule is particularly easy to misread when a short declaration handles several names at once.
Shadowing is not a syntax error. Sometimes a short-lived inner value can reasonably reuse a short name, but shadowing err, a result variable, or a state flag often changes control flow. Judge the name in the context of ownership and where later reads occur.
Expressions and explicit conversions
Go does not automatically convert between ordinary numeric variables. Before multiplying an int by a float64, you must write an explicit conversion such as float64(quantity). A conversion produces a new value of the target type and may discard information; it does not change the source variable’s type.
Constants are more flexible. An untyped numeric constant can retain an exact value until assignment, an explicit conversion, a function call, or another context requires a concrete type. The value must be representable by the target type, so var level uint8 = 255 compiles while assigning the constant 256 to uint8 fails at compile time.
Operators accept only the operand combinations defined by the language. Go has no truthiness rule that treats 0 or an empty string as a boolean; a condition must have type bool. ++ and -- are statements, so they cannot appear inside an expression or function argument.
Assignment evaluates the right side first
A multi-value assignment first determines the left operands and evaluates every right-side expression, then performs assignments from left to right. This makes left, right = right, left a valid swap without a temporary variable. It also means you must review a multi-value assignment as a unit rather than assume its first assignment has affected the calculation of a later right-side expression.
Compound assignments such as += and -= require an existing left operand and combine its read, operation, and write into one statement. They do not declare a variable. count++ and count-- likewise update an existing variable and produce no result for another expression to consume.
Several values returned by a function can directly fill a multi-value assignment, as in cost, supported = shippingCost(country, subtotal). If the left side instead uses :=, the short declaration rule still requires at least one new name in the current block. The mere presence of several names does not make all of them new variables.
Functions and control flow
A function signature states parameter and result types. Adjacent parameters of one type can be written as func add(left, right int) int, and a function can return several results. Multiple results commonly deliver a domain value together with a status boolean or an error.
An if can execute a short statement before its condition, as in if value, err := read(); err != nil { ... }. Names declared there are visible only throughout that if and its branches. Declaring a result there when later code still needs it causes a scope error.
A switch chooses the first matching branch and exits after executing it; individual branches need no break. One case can list several expressions, and a switch without an expression can replace a less readable if/else if chain. fallthrough unconditionally enters the next branch body, so ordinary grouping does not require it.
Go has only the for loop statement, with three common forms: a loop with initialization, condition, and post statement; a condition-only loop; and iteration with range. break ends a loop or switch, while continue starts the next iteration. A label can target an outer loop, but simple control flow rarely needs one.
Toolchain feedback
gofmt applies standard whitespace, indentation, and import grouping so the syntax is easier to review. It is not a type checker and does not prove behavior; a successfully formatted file can still fail to compile.
go run file.go compiles and runs a small program, which suits the standalone examples in this topic. Project code usually uses go test ./... to compile packages and run tests together. Resolve scope, type, and unused-name diagnostics from the compiler before assessing the business failures exposed by tests.
Run build commands from the correct module directory because the module file affects language versions and dependency selection. Copying generated code into an isolated temporary file verifies only that file; it does not replace a build and test in the real package.
Tool output is evidence about syntax and behavior, not decoration to reconstruct from memory.
Examples
The four programs below add concepts in sequence. Each code block can be saved separately and run with go run filename; the displayed output came from the local Go toolchain.
Produce a result from declarations
This program combines a package, an import, an untyped constant, a zero-valued variable, short declarations, and an explicit conversion. pendingOrders starts at its zero value, while the other local variables infer types from their initializers.
package main
import "fmt"
const taxRate = 0.20
func main() {
var pendingOrders int
item := "keyboard"
quantity := 2
unitPrice := 75.0
subtotal := float64(quantity) * unitPrice
total := subtotal * (1 + taxRate)
pendingOrders++
fmt.Println("item:", item)
fmt.Println("pending before shipment:", pendingOrders)
fmt.Printf("subtotal: %.2f\n", subtotal)
fmt.Printf("total: %.2f\n", total)
}item: keyboard
pending before shipment: 1
subtotal: 150.00
total: 180.00The inferred type of quantity is int, while the inferred type of unitPrice is float64. They cannot be multiplied directly, so the program explicitly converts the quantity to float64. taxRate is representable as float64 in the multiplication context and needs no separate conversion.
Here pendingOrders++ is a complete statement. Writing fmt.Println(pendingOrders++) would fail to compile because increment is not an expression that produces a value.
Express one decision with a function
shippingCost returns both a cost and whether the destination is supported. The caller handles the unsupported case before using the cost. This is clearer than making a special cost value mean both an ordinary result and failure.
package main
import "fmt"
func shippingCost(country string, subtotal int) (int, bool) {
if subtotal >= 100 {
return 0, true
}
switch country {
case "FR", "DE":
return 8, true
case "GB":
return 12, true
default:
return 0, false
}
}
func main() {
countries := [3]string{"FR", "GB", "CA"}
subtotals := [3]int{120, 75, 50}
for index, country := range countries {
cost, supported := shippingCost(country, subtotals[index])
if !supported {
fmt.Printf("%s: unavailable\n", country)
continue
}
fmt.Printf("%s: subtotal=%d shipping=%d\n", country, subtotals[index], cost)
}
}FR: subtotal=120 shipping=0
GB: subtotal=75 shipping=12
CA: unavailableThe first order qualifies for free shipping because its subtotal reaches 100, so the function returns before the switch. The second matches GB, and the third reaches default. Each matching branch here returns, but Go’s switch would not automatically enter the next branch even without those returns.
The loop uses index to read the subtotal in the corresponding position, while country is a copy of that array element. A real program would usually put related fields in one struct. The parallel arrays here keep the example focused on functions and control flow.
Separate bytes, code points, and decimal text
The string below contains two ASCII characters and two Chinese code points. The program also compares converting an integer to a string with formatting that integer as decimal text. Those operations have different meanings.
package main
import (
"fmt"
"strconv"
"unicode/utf8"
)
func main() {
label := "Go语言"
fmt.Println("bytes:", len(label))
fmt.Println("runes:", utf8.RuneCountInString(label))
for byteIndex, codePoint := range label {
fmt.Printf("byte %d: %c\n", byteIndex, codePoint)
}
code := 65
fmt.Println("string(65):", string(code))
fmt.Println("strconv.Itoa(65):", strconv.Itoa(code))
}bytes: 8
runes: 4
byte 0: G
byte 1: o
byte 2: 语
byte 5: 言
string(65): A
strconv.Itoa(65): 65len(label) returns the 8 bytes in its UTF-8 encoding. The range indices are therefore 0, 1, 2, and 5, not consecutive code-point positions. utf8.RuneCountInString decodes the string and reports 4 code points.
string(code) interprets the integer 65 as Unicode code point U+0041, so its result is A. strconv.Itoa(code) is the operation that writes the integer as decimal text, "65".
Pitfalls
:= shadows the variable you meant to update
A common case writes result, err := operation() inside an if, then assumes that the function’s outer result was updated. Fix: declare names in the scope that needs them and assign with =. Also enable compiler or editor shadow diagnostics and test the value actually returned on the success path.
Treating a byte index as a character index
Fix: use []byte explicitly when a protocol deals in bytes. Use range or []rune when it deals in Unicode code points. If the boundary is a user-perceived grapheme cluster, use a dedicated Unicode segmentation implementation; counting runes is still insufficient.
Assuming a conversion is lossless
Converting a floating-point value to an integer discards its fractional part, and narrower numeric types cannot represent every source value. Fix: check the accepted range before conversion and test boundary values. For number-to-text conversion, use strconv or formatting rather than the code-point semantics of string(integer).
Using the blank identifier to hide errors
Fix: discard a result only when both the API contract and the calling context prove it irrelevant. Handle or return an error explicitly. Do not mechanically replace diagnostics with _ just to make generated code compile.
Expecting switch to fall through
Fix: list values in one case when they share behavior, as in case "FR", "DE":. Use fallthrough only when the next branch body must execute unconditionally, and do not use it to simulate complex conditions.
Untyped constants and representability
A Go constant is not a read-only variable. An untyped constant can retain an exact value within the precision required by the language and does not have the fixed type of an ordinary variable until a concrete context appears. Integer, rune, floating-point, complex, Boolean, and string constants each have a default type.
The default type is used only when a context needs an ordinary type. For example, value := 3 gives the untyped integer constant its default type, int, while assigning the same constant directly to a uint8 variable supplies a uint8 context. An any parameter supplies no more specific target type, so the constant takes its default type there too.
Context supplies the type
Below, maxRetries can be assigned to uint8 because that type can represent the value 8. exactThird remains an exact constant until assignment to float32 rounds it. Passing it directly to fmt.Printf instead applies its default type, float64.
package main
import "fmt"
const (
maxRetries = 1 << 3
exactThird = 1.0 / 3.0
)
func main() {
var retries uint8 = maxRetries
var rounded float32 = exactThird
fmt.Printf("retries: %d (%T)\n", retries, retries)
fmt.Printf("rounded: %.9f\n", rounded)
fmt.Printf("default type: %T\n", exactThird)
}retries: 8 (uint8)
rounded: 0.333333343
default type: float64Representability of a constant assignment is checked at compile time. Changing maxRetries to 1 << 8 makes var retries uint8 = maxRetries fail because 256 is outside the value set of uint8. The failure cannot be deferred until runtime.
Conversions of ordinary variables follow different rules. A variable already has a concrete type and value, and an explicit conversion can lose information at runtime. The fact that the compiler rejects an analogous constant assignment does not imply that every variable conversion receives the same protection.
Loop variables after Go 1.22
Since Go 1.22, a variable declared with := in a for initializer has a separate instance for each iteration. Iteration variables declared with := in a range clause have the same per-iteration semantics. The value := value closure-capture workaround common in older material is generally redundant in new code compiled with the Go 1.22 or later language version.
The behavior still depends on the declaration form and the Go language version selected by the module. A range clause that uses = to assign variables declared outside the loop continues to reuse those variables. When reviewing generated concurrent callbacks, inspect the go directive in go.mod and the loop spelling before deciding whether capture is wrong.
The boundary of strict compiler checks
The Go compiler rejects unused imports and local variables inside functions, but it permits unused package-level declarations. The blank identifier can explicitly discard values and can request an import solely for side effects. Both forms should communicate real intent rather than serve as universal ways to erase diagnostics.
Successful compilation proves only that code satisfies language and type rules. It does not prove that units are correct, input was validated, errors were retained, or string boundaries match the product’s definition of a “character.” Pair basic syntax checks with tests of inputs and behavior.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug