Basic Syntax
For developers with .NET (C#) experience, this guide covers variable declarations, constants, control flow, zero values, functions, multiple return values, and defer.
1. Variable Declarations
var and :=
var count int // explicit type (initialized to the zero value 0)
var name = "gopher" // type inference
age := 30 // short declaration (inside functions only)
var ( // grouped declaration
host = "localhost"
port = 8080
)
a, b := 1, "two" // multiple at once
| Form | Where allowed | Use for |
|---|---|---|
var x T | Anywhere | Zero-value initialization or an explicit type |
var x = v | Anywhere | Package-level variables, etc. |
x := v | Inside functions only | The standard way to declare locals |
Notes:
- Unused local variables are compile errors (C# only warns). Discard unneeded return values with
_ :=requires at least one new variable on the left. If all are existing variables, use=- The type goes after the name, not before (
var x int,func f(a int) string) - There are no implicit conversions. Even
intandint64need an explicit conversion
var i int = 10
var f float64 = float64(i) * 1.5 // explicit conversion required
Scope and shadowing
x := 1
if true {
x := 2 // a new variable (shadows the outer x)
_ = x
}
fmt.Println(x) // 1
This is especially bug-prone with err. Using := inside an if or for means the outer variable is not updated.
var result string
var err error
if cond {
result, err := doSomething() // different from the outer result / err!
_, _ = result, err
}
2. Basic Types
| Go | C# | Notes |
|---|---|---|
bool | bool | |
int, int8 to int64 | int, sbyte to long | int is platform dependent (64-bit on 64-bit systems) |
uint, uint8 to uint64 | uint, byte to ulong | |
byte | byte | Alias for uint8 |
rune | char (roughly) | A Unicode code point (alias for int32) |
float32, float64 | float, double | |
string | string | An immutable byte sequence (UTF-8) |
any | object | Alias for interface{} |
Strings
s := "こんにちは"
fmt.Println(len(s)) // 15 (bytes)
fmt.Println(len([]rune(s))) // 5 (characters)
for i, r := range s { // iterates by rune (i is the byte offset)
fmt.Println(i, string(r))
}
raw := `C:\path\to\file` // raw string (similar to C# @"...")
- A string is a UTF-8 byte sequence.
lenreturns bytes, ands[i]is abyte - To work per character, convert to
[]runeor iterate withrange +concatenation works, but for many concatenations in a loop usestrings.Builder(likeStringBuilder)
3. Constants and iota
const Pi = 3.14159
const MaxRetry = 3
const (
StatusPending = "pending"
StatusDone = "done"
)
- A
constmust be known at compile time. Runtime values, slices, maps, and structs cannot be constants - Untyped constants take on a type as needed
Enumerations with iota
Go has no enum. Use const with iota.
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
)
func (d Weekday) String() string {
switch d {
case Sunday:
return "Sunday"
case Monday:
return "Monday"
case Tuesday:
return "Tuesday"
default:
return "Unknown"
}
}
- Range checks are not automatic.
Weekday(99)is still valid - Bit flags can be expressed with
1 << iota
4. Zero Values
In Go, a declared variable is always initialized to its type's zero value. There is no uninitialized state.
| Type | Zero value |
|---|---|
int, float64, etc. | 0 |
bool | false |
string | "" (empty string, never null) |
Pointers, slice, map, chan, func, interface | nil |
struct | All fields set to their zero values |
| Arrays | All elements set to their zero values |
var s string // ""
var p *int // nil
var m map[string]int // nil (reads are fine, writes panic)
var xs []int // nil (len is 0, append works)
"Make the zero value useful" is a Go design principle. sync.Mutex and bytes.Buffer work immediately without initialization.
Differences from null
- Strings and structs are never
null, which removes many causes ofNullReferenceException - To express "no value", use a pointer (
*int). There is no standardNullable<T> - A
nilslice and an empty slice behave the same in most cases (JSON distinguishesnullfrom[])
5. Control Flow
if
if x > 10 {
// ...
} else if x > 5 {
// ...
} else {
// ...
}
// With an init statement (scopes the variable to the if)
if err := doSomething(); err != nil {
return err
}
- No parentheses around the condition; braces are required (even for one line)
- The condition must be a
bool(no implicit conversions likeif x) - There is no ternary operator
? :
for (the only loop)
There is no while / do-while / foreach. Everything is a for.
// classic for
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// while equivalent
for n < 100 {
n *= 2
}
// infinite loop
for {
if done() {
break
}
}
// foreach equivalent (range)
nums := []int{10, 20, 30}
for i, v := range nums {
fmt.Println(i, v)
}
for _, v := range nums { _ = v } // discard the index
for i := range nums { _ = i } // index only
// maps and strings
for k, v := range map[string]int{"a": 1} { _, _ = k, v }
// range over an integer (Go 1.22+)
for i := range 5 {
fmt.Println(i) // 0..4
}
- Since Go 1.22, loop variables are created fresh per iteration. The old trap of capturing loop variables in closures or goroutines is gone (when the
goline ingo.modis 1.22 or later) mapiteration order is random. If you need order, extract and sort the keysbreak/continueaccept labels to control outer loops
outer:
for _, row := range matrix {
for _, v := range row {
if v < 0 {
break outer
}
}
}
switch
switch day {
case "sat", "sun": // multiple values
fmt.Println("weekend")
case "mon":
fmt.Println("start")
default:
fmt.Println("weekday")
}
// Expression-less switch (replaces if-else chains)
switch {
case score >= 90:
grade = "A"
case score >= 70:
grade = "B"
default:
grade = "C"
}
- No fall-through:
breakis unnecessary. Usefallthroughexplicitly to continue to the next case - Cases can be non-constant expressions
- For type-based branching use a type switch (similar to C# pattern matching
switch)
func describe(v any) string {
switch x := v.(type) {
case int:
return fmt.Sprintf("int: %d", x)
case string:
return "string: " + x
case nil:
return "nil"
default:
return fmt.Sprintf("other: %T", x)
}
}
6. Functions
Basics
func add(a, b int) int {
return a + b
}
func greet(name string, times int) {
for range times {
fmt.Println("Hello,", name)
}
}
- Functions are first-class values. You can assign them to variables and pass them as arguments
- There is no overloading. You cannot define multiple functions of the same name with different parameters
- There are no default or optional parameters either. Use structs or the functional options pattern
Variadic parameters
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sum(1, 2, 3)
xs := []int{1, 2, 3}
sum(xs...) // spread a slice (like params)
Anonymous functions and closures
counter := func() func() int {
c := 0
return func() int {
c++
return c
}
}()
fmt.Println(counter(), counter()) // 1 2
Multiple return values
Instead of tuples or out parameters in C#, a function can return several values.
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
q, err := divide(10, 2)
if err != nil {
return err
}
- Placing an
erroras the last return value is the standard (see Error Handling) - Discard unneeded values with
_:q, _ := divide(10, 2) - The
TryParsepattern is expressed as(value, ok bool):v, ok := m["key"]
Named return values
func minMax(xs []int) (min, max int) {
min, max = xs[0], xs[0]
for _, x := range xs[1:] {
if x < min {
min = x
}
if x > max {
max = x
}
}
return // naked return: returns min, max
}
- Naming return values documents their meaning and lets
defermodify them - Restrict naked returns (a bare
return) to short functions. They hurt readability in long ones
7. defer
defer schedules a call to run when the function exits. It replaces C# try / finally and using.
func readFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close() // always runs when the function exits
return io.ReadAll(f)
}
Behavior rules
- Deferred calls run in LIFO (last in, first out) order
- Arguments are evaluated when the
deferstatement executes - They run on every exit path (
return/panic) - A
deferin a loop accumulates until the function ends
func main() {
for i := 0; i < 3; i++ {
defer fmt.Println("deferred", i)
}
fmt.Println("done")
}
// Output:
// done
// deferred 2
// deferred 1
// deferred 0
func trace() {
x := 1
defer fmt.Println("x =", x) // argument fixed at the current value (1)
x = 2
}
// Output: x = 1
Beware defer in loops
// Bad: no file is closed until the function returns
for _, p := range paths {
f, _ := os.Open(p)
defer f.Close()
}
// Good: extract a function
for _, p := range paths {
if err := process(p); err != nil {
return err
}
}
func process(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// ...
return nil
}
Typical uses
mu.Lock()
defer mu.Unlock() // release a lock
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback() // harmless after Commit
start := time.Now()
defer func() {
slog.Info("done", "elapsed", time.Since(start))
}() // timing with an anonymous function
For handling panic with defer and recover, see Error Handling.
8. .NET Mapping
| .NET (C#) | Go |
|---|---|
var / type inference | var / := |
null / default | Zero value |
try / finally / using | defer |
Tuples / out parameters | Multiple return values |
while / foreach / for | for only (including range) |
switch expressions / pattern matching | switch / type switch |
enum | const + iota |
params | Variadic parameters (...T) |
| Lambda expressions | Anonymous functions (function literals) |
| Method overloading | None |
| Optional parameters | None (use structs or functional options) |
| Ternary operator | None (use if) |
9. Common Pitfalls for C# Developers
- Unused variables and imports are errors: this happens often while experimenting. Discard explicitly with
_or remove them - Shadowing with
:=: redeclaringerrin an inner scope leaves the outer one unchanged - Writing to a
nilmap panics: initialize withmake(map[string]int)or a literal - No implicit conversions: mixing
int,int64, andfloat64requires explicit conversion deferaccumulates in loops: extract a function so resources are released early- String length is in bytes: be mindful of
runewhen handling Japanese or other multibyte text switchdoes not fall through: this matches C#'s feel (and differs from C)rangeyields copies of values: modifyingvinfor _, v := range itemsdoes not change the original element (write via the index)- Brace placement is fixed:
{must be on the same line. A line break triggers automatic semicolon insertion and a compile error (gofmtformats this for you)
10. Code Example
C#
public static (int Quotient, string? Error) Divide(int a, int b)
{
if (b == 0) return (0, "division by zero");
return (a / b, null);
}
public static string ReadFirstLine(string path)
{
using var reader = new StreamReader(path);
return reader.ReadLine() ?? "";
}
foreach (var (i, v) in nums.Select((v, i) => (i, v)))
{
Console.WriteLine($"{i}: {v}");
}
Go
package main
import (
"bufio"
"errors"
"fmt"
"os"
)
type Level int
const (
Debug Level = iota
Info
Warn
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func readFirstLine(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close() // like using
scanner := bufio.NewScanner(f)
if scanner.Scan() {
return scanner.Text(), nil
}
return "", scanner.Err()
}
func main() {
q, err := divide(10, 2)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("quotient:", q)
nums := []int{10, 20, 30}
for i, v := range nums {
fmt.Printf("%d: %d\n", i, v)
}
line, err := readFirstLine("sample.txt")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(line, Info)
}
11. Summary
- Declare variables with
varand:=;:=is standard inside functions. Unused variables are compile errors, and there are no implicit conversions. - Zero values are always assigned, so uninitialized data and
nullreferences are rare. Still, watch out fornilmaps, slices, and pointers. foris the only loop;rangereplacesforeach, andswitchdoes not fall through. Represent enums withconst+iota.- Functions can return multiple values.
(T, error)and(T, bool)are the standard shapes; there is no overloading or default parameters. deferreplacestry / finallyandusing. Remember: LIFO, arguments evaluated immediately, accumulates inside loops.
Next, see Types and Structs.