Skip to main content

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
FormWhere allowedUse for
var x TAnywhereZero-value initialization or an explicit type
var x = vAnywherePackage-level variables, etc.
x := vInside functions onlyThe 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 int and int64 need 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​

GoC#Notes
boolbool
int, int8 to int64int, sbyte to longint is platform dependent (64-bit on 64-bit systems)
uint, uint8 to uint64uint, byte to ulong
bytebyteAlias for uint8
runechar (roughly)A Unicode code point (alias for int32)
float32, float64float, double
stringstringAn immutable byte sequence (UTF-8)
anyobjectAlias 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. len returns bytes, and s[i] is a byte
  • To work per character, convert to []rune or iterate with range
  • + concatenation works, but for many concatenations in a loop use strings.Builder (like StringBuilder)

3. Constants and iota​

const Pi = 3.14159
const MaxRetry = 3

const (
StatusPending = "pending"
StatusDone = "done"
)
  • A const must 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.

TypeZero value
int, float64, etc.0
boolfalse
string"" (empty string, never null)
Pointers, slice, map, chan, func, interfacenil
structAll fields set to their zero values
ArraysAll 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 of NullReferenceException
  • To express "no value", use a pointer (*int). There is no standard Nullable<T>
  • A nil slice and an empty slice behave the same in most cases (JSON distinguishes null from [])

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 like if 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 go line in go.mod is 1.22 or later)
  • map iteration order is random. If you need order, extract and sort the keys
  • break / continue accept 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: break is unnecessary. Use fallthrough explicitly 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 error as the last return value is the standard (see Error Handling)
  • Discard unneeded values with _: q, _ := divide(10, 2)
  • The TryParse pattern 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 defer modify 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​

  1. Deferred calls run in LIFO (last in, first out) order
  2. Arguments are evaluated when the defer statement executes
  3. They run on every exit path (return / panic)
  4. A defer in 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 inferencevar / :=
null / defaultZero value
try / finally / usingdefer
Tuples / out parametersMultiple return values
while / foreach / forfor only (including range)
switch expressions / pattern matchingswitch / type switch
enumconst + iota
paramsVariadic parameters (...T)
Lambda expressionsAnonymous functions (function literals)
Method overloadingNone
Optional parametersNone (use structs or functional options)
Ternary operatorNone (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 :=: redeclaring err in an inner scope leaves the outer one unchanged
  • Writing to a nil map panics: initialize with make(map[string]int) or a literal
  • No implicit conversions: mixing int, int64, and float64 requires explicit conversion
  • defer accumulates in loops: extract a function so resources are released early
  • String length is in bytes: be mindful of rune when handling Japanese or other multibyte text
  • switch does not fall through: this matches C#'s feel (and differs from C)
  • range yields copies of values: modifying v in for _, v := range items does 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 (gofmt formats 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 var and :=; := is standard inside functions. Unused variables are compile errors, and there are no implicit conversions.
  • Zero values are always assigned, so uninitialized data and null references are rare. Still, watch out for nil maps, slices, and pointers.
  • for is the only loop; range replaces foreach, and switch does not fall through. Represent enums with const + iota.
  • Functions can return multiple values. (T, error) and (T, bool) are the standard shapes; there is no overloading or default parameters.
  • defer replaces try / finally and using. Remember: LIFO, arguments evaluated immediately, accumulates inside loops.

Next, see Types and Structs.

References​