1. Multiple assignment is interesting
When you’re just starting out, it seems like: “Well, assignment is simple — write a = ..., then b = ..., and you’re done.” The problem appears when the values are connected: you need to update several variables so that they “move” at the same time and don’t interfere with each other. This is where Go gives you a very handy tool: multiple (parallel) assignment.
In Go, you can assign multiple values to multiple variables at once:
a, b = 10, 20
This is read as: “put 10 into a, put 20 into b.”
Key idea: the right-hand side is evaluated completely
The right-hand side is evaluated completely, and only then are the results distributed into the variables on the left. This means you can do updates that are easy to break in a “step-by-step” version.
Let’s look at an example that feels a little like magic, but is actually honest:
package main
import "fmt"
func main() {
x := 1
y := 2
x, y = y, x+y
fmt.Println(x, y) // 2 3
}
The important part is that x+y is computed using the old values (1+2), not using the already updated x. That’s why the result is predictable: x became 2, and y became 3.
Swapping values without a temporary variable
When a programmer first sees a swap without a “third cup,” they usually think: “This is witchcraft, call a compiler exorcist immediately.” In reality, it’s just multiple assignment.
Instead of this (classic) approach:
tmp = a
a = b
b = tmp
in Go you write:
package main
import "fmt"
func main() {
a := 5
b := 9
a, b = b, a
fmt.Println(a, b) // 9 5
}
This is useful in tasks where you want to “normalize” input. For example, the user enters two numbers, and you want the first one to be less than or equal to the second.
Mini-app: range normalizer
To make the examples more concrete, let’s agree on this: we have a mini program that reads two integers from and to, and it wants to work with the range in the correct order: from <= to. If the user enters them the other way around, we’ll neatly swap them.
Note: here multiple assignment is not a “trick for the sake of a trick,” but a direct tool for the logic.
package main
import "fmt"
func main() {
from := 0
to := 0
fmt.Scan(&from, &to)
if from > to {
from, to = to, from // swap
}
fmt.Printf("range: %d..%d\n", from, to) // for example: range: 3..10
}
Notice that inside if we use =, not :=. This is not an accident, but protection from a very nasty mistake that we’ll get to soon.
2. := and the “at least one new name” rule
You already know the short declaration :=: it means “declare a variable and assign a value right away.” But today we care about the subtler part: what happens when there are multiple names on the left.
In the current scope (in the current block {...}), the := construct is allowed only when at least one name on the left is declared for the first time in this block.
That is:
- if all names already exist in this block, := is forbidden;
- if some names already exist but at least one new one appears, := is allowed, and the existing variables simply receive new values.
This rule sounds dry, but in practice it saves you from ambiguity: the compiler must understand whether you are creating a new variable or updating an old one.
Example: one new name — works
package main
import "fmt"
func main() {
x := 10
y := 20
x, z := y, 30 // z is a new name, so := is allowed
fmt.Println(x, y, z) // 20 20 30
}
In this line, two actions happen at once: x is reused, and z is created.
Example: all names already exist — does not work
package main
import "fmt"
func main() {
x := 1
y := 2
// x, y := 3, 4 // not allowed: there is no new name in this block
x, y = 3, 4 // correct
fmt.Println(x, y) // 3 4
}
If you uncomment the line with :=, the compiler will refuse to compile the program. And that’s good: it prevents you from “accidentally redeclaring” something you meant to update.
3. Shadowing: updating the wrong thing without noticing
Now for the “tastiest” and most unpleasant part: shadowing (hiding a variable).
Shadowing is a situation where, in an inner block, you create a new variable with the same name as in an outer block. Inside the block you see the new variable, while the outer one remains unchanged. The bug is tricky because the program can look “logical” and even print “correct” numbers... but only inside the block.
The shortest shadowing example
package main
import "fmt"
func main() {
x := 10
if x > 0 {
x := 99
fmt.Println("inside:", x) // inside: 99
}
fmt.Println("outside:", x) // outside: 10
}
Inside if, the line x := 99 does not “update x,” but creates a new variable named x, which exists only inside the if.
If you expected 99 outside as well, that’s the classic trap.
Why it is so easy to do accidentally
Because := is very convenient, and your hand starts writing it “by inertia.” Especially inside if, for, and other blocks. But inside a block you sometimes need to stop and ask yourself: “Do I want to create a new variable or update an existing one?”
Shadowing in the range normalizer
Let’s return to the program with from and to. We want to swap the values if from > to.
Here is the correct version:
if from > to {
from, to = to, from
}
And here is a version that looks suspiciously similar... but does something else:
package main
import "fmt"
func main() {
from := 0
to := 0
fmt.Scan(&from, &to)
if from > to {
from, to := to, from // shadowing: created new from/to inside if
fmt.Printf("inside: %d..%d\n", from, to)
}
fmt.Printf("outside: %d..%d\n", from, to)
}
What is happening here?
Inside if, we really do get the “correct” order and even print it nicely. But outside, everything remains as it was. This is one of those bugs that is perfectly disguised as “but it works, I can see the output.”
if with an init statement: another risk zone
Go has a popular if form where you can do a short declaration before the condition:
if v := something; v > 0 {
...
}
This construct is convenient, but it creates yet another chance to accidentally hide a variable.
Watch closely:
package main
import "fmt"
func main() {
result := 100
if result := 5; result < 10 {
fmt.Println("inside:", result) // inside: 5
}
fmt.Println("outside:", result) // outside: 100
}
Inside if, result := 5 creates a new result that exists only until the closing }. The outer result is not affected at all.
If you wanted a temporary variable — great. If you wanted to update the outer one — you need to write it differently (usually result = 5, and then the assignment must be before the if).
4. Global variables
So far our program has one function main, and you can keep almost everything “inside.” But as soon as you have several functions, you often need something shared: settings, constants, lookup tables, “debug mode,” common messages. In Go, for that you have package-level variables — they are often called “global,” although more precisely they are package-level.
Such variables are declared outside functions, directly in the file:
package main
import "fmt"
var appName = "Range Normalizer" // global (package-level) variable
func normalize(from, to int) (int, int) {
if from > to {
from, to = to, from
}
return from, to
}
func main() {
from, to := 10, 3
from, to = normalize(from, to)
fmt.Println(appName, from, to) // Range Normalizer 3 10
}
Pay attention to two important things:
- Outside functions, you cannot use :=.
Short declaration works only inside blocks {...}. At the package level you write var (or const). - Global variables are visible to all functions in this package, so they really help when the number of functions grows: you don’t need to thread the same thing through parameters every time.
But then a classic trap appears immediately: shadowing with global variables. Inside a function, it’s very easy to habitually write := and create a new local variable with the same name — and “accidentally disable” your global one.
package main
import "fmt"
var debug = false
func main() {
debug := true // shadowing! created a new local debug
fmt.Println("inside:", debug) // inside: true
}
func other() {
fmt.Println("global:", debug) // will see the global: false
}
That’s why it’s useful to be careful with global variables: when you see :=, always ask yourself: “Am I creating a new variable or updating an existing one?”
5. Practice: diagnostics and a robust style
Diagnostics via fmt.Printf and inside/outside
When you suspect shadowing (or you’re simply not sure which variable you are touching right now), it helps to print values at block boundaries. This is a classic approach: until you have a complex project, Printf is your best friend.
Here’s a mini technique: print “inside/outside” and see whether the outer variable changes.
package main
import "fmt"
func main() {
x := 1
fmt.Printf("start x=%d\n", x) // start x=1
if x == 1 {
x := 2
fmt.Printf("inside x=%d\n", x) // inside x=2
}
fmt.Printf("end x=%d\n", x) // end x=1
}
If you see that “inside” and “end” differ unexpectedly, you almost certainly did shadowing by accident.
A small historical note: the redeclaration rule in Go (where := can be used if at least one new name appears) is a compromise between convenience and code readability, and many debates have been fought over it.
Multiple assignment + input: be careful with _
fmt.Scan returns two values: the number of successfully scanned values and an error. We don’t need to dive deep into errors today, but it’s useful to understand that a function can return multiple results — and that pairs perfectly with multiple assignment.
If you just want to read two numbers and not analyze how many were actually read, you can temporarily ignore the returned values using _ (blank identifier). This is legal and often convenient in learning examples:
package main
import "fmt"
func main() {
a := 0
b := 0
_, _ = fmt.Scan(&a, &b)
fmt.Printf("a=%d b=%d\n", a, b) // for example: a=3 b=10
}
Note: _ is a “trash can,” not a variable. You can “throw away” a value there, but you can’t read it back.
When := is better, and when = is better
It’s useful to build a simple habit that saves hours: := is good for first initialization, while = is for later updates. It sounds like advice on the level of “drink water,” but it works.
To avoid keeping everything in your head, here’s a small reminder table:
| Syntax | What it does | When to use |
|---|---|---|
|
declares x and assigns 10 | when the variable appears for the first time in the block |
|
declares x with a zero value | when you want to explicitly set the type or postpone assignment |
|
updates an existing variable x | when x is already declared and you are changing the value |
|
updates multiple variables at the same time | swaps, normalization, related updates |
If you are writing inside a block and catch yourself thinking “I just need to update it,” you almost always need =, not :=.
6. Common mistakes
Mistake #1: using := “out of habit” inside if and accidentally doing shadowing.
It’s very easy to write value := value + 1 inside a block, thinking you’re updating the variable. In reality, you create a new variable with the same name that lives exactly until the end of the block. The symptom is usually this: inside if everything prints “correctly,” but after if the value seems to “roll back.”
Mistake #2: not understanding the “at least one new name” rule and being surprised why := sometimes works and sometimes doesn’t.
In one line, x, z := y, 30 compiles because z is a new name. But the line x, y := 3, 4 in the same block does not compile because x and y already exist. This is not a whim, but a language rule: := must declare something; otherwise you need =.
Mistake #3: trying to swap values “step by step” and accidentally losing data.
A construct like a = b; b = a is almost always wrong: after the first line, you have lost the old value of a. In Go, a correct swap is done in one line: a, b = b, a. This is exactly the case where multiple assignment is not decoration, but a correctness guarantee.
Mistake #4: keeping identical names in different blocks without a reason.
Sometimes shadowing happens “by accident,” and sometimes “because it just worked out that way.” But for readability it’s better to avoid identical names if the variables are truly different in meaning. When you see x outside and x inside, your brain is forced to constantly remember the scopes, and that’s an expensive cognitive operation (programmers are people too; sometimes they don’t feel like thinking).
Mistake #5: not checking “inside/outside” while debugging and wasting time on mysticism.
Shadowing is one of those bugs you can catch in a minute with a simple fmt.Printf("inside...") and fmt.Printf("outside..."). If you see a discrepancy, it means a new variable was created somewhere. If you don’t, the problem is elsewhere — and that is also useful information.
GO TO FULL VERSION