Output & Running
Hello, World
Two languages that both keep a wrapper around the smallest program, arranged almost identically.
Option Strict On
Imports System
Module HelloWorld
Sub Main()
Console.WriteLine("Hello, World!")
End Sub
End Modulepackage main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}Module becomes package main — the package named main is what produces an executable — and Sub Main() becomes func main(). Imports System becomes import "fmt", naming the package that holds the printing functions. Blocks use braces and there are no semicolons; the compiler inserts them at line ends, which is why the opening brace must stay on the same line. Indentation is tabs, decided by gofmt rather than by you.Formatted output
There is no interpolated string; the format verbs do the work, and one of them is worth learning immediately.
Option Strict On
Imports System
Module FormatDemo
Sub Main()
Dim name As String = "Ada"
Dim score As Integer = 42
Dim ratio As Double = 0.8756
Console.WriteLine($"Hello, {name}! Score: {score}")
Console.WriteLine($"Padded: {score:D5}, rounded: {ratio:F2}")
End Sub
End Modulepackage main
import "fmt"
func main() {
name := "Ada"
score := 42
ratio := 0.8756
fmt.Printf("Hello, %s! Score: %d\n", name, score)
fmt.Printf("Padded: %05d, rounded: %.2f\n", score, ratio)
fmt.Printf("%v and %+v\n", []int{1, 2}, struct{ A int }{1})
}Go has no
$"...". fmt.Printf uses C-style verbs — %s, %d, %.2f, %05d — and needs an explicit \n, while fmt.Println supplies one. The verb with no .NET counterpart is %v, "print this value however it should look", and %+v, which adds struct field names. Between them they replace most of what you would write a ToString override for.Unused things are errors
The first thing that will stop your build, and it is a deliberate design decision rather than an oversight.
Option Strict On
Imports System
Imports System.Text
Module UnusedDemo
Sub Main()
' System.Text is imported and unused — a warning at most
Dim total As Integer = 10
Dim unusedValue As Integer = 5
Console.WriteLine(total)
End Sub
End Modulepackage main
import "fmt"
func main() {
total := 10
// An unused variable or import is a COMPILE ERROR:
// unusedValue := 5 // declared and not used
// import "strings" // imported and not used
_ = total // the blank identifier deliberately discards
fmt.Println(total)
}An unused local variable or an unused import is a compile error in Go, not a warning. This is jarring while you are experimenting and valuable in a codebase: dead code cannot accumulate. The escape hatch is
_, the blank identifier, which discards a value on purpose — you will use it constantly for return values you do not want. Unused struct fields, function parameters and package-level variables are all still fine.Syntax Fundamentals
Dim becomes := (or var)
Two forms: one that infers and declares in a single token, and one that spells the type out.
Option Strict On
Imports System
Module DeclarationDemo
Sub Main()
Dim count As Integer = 10
Dim label As String = "widget"
Dim ready As Boolean
Const MaximumRetries As Integer = 3
count = 20
Console.WriteLine($"{count} {label} {ready} {MaximumRetries}")
End Sub
End Modulepackage main
import "fmt"
const MaximumRetries = 3
func main() {
count := 10
label := "widget"
var ready bool // zero value: false
count = 20
fmt.Println(count, label, ready, MaximumRetries)
}:= declares and infers in one step and works only inside a function — it is what you will write nearly always. var name Type is the explicit form, and it is what you need when there is no initializer, at package level, or when the inferred type would be wrong. The type goes after the name, reading left to right: var ready bool. Const becomes const, and constants are untyped until used, which lets one constant serve several numeric types.A capital letter is the access modifier
There is no
Public or Private keyword at all — the first letter of the name decides.Option Strict On
Imports System
Public Class Report
Public Property Title As String = ""
Private _internalId As Integer = 1
Public Function Describe() As String
Return $"{Title} ({_internalId})"
End Function
End Class
Module ExportDemo
Sub Main()
Dim report As New Report With {.Title = "Quarterly"}
Console.WriteLine(report.Describe())
End Sub
End Modulepackage main
import "fmt"
type Report struct {
Title string // exported — visible outside the package
internalID int // lower case — package-private
}
func (r Report) Describe() string {
return fmt.Sprintf("%s (%d)", r.Title, r.internalID)
}
func main() {
report := Report{Title: "Quarterly", internalID: 1}
fmt.Println(report.Describe())
}A name beginning with a capital letter is exported from its package; a lower-case one is not. That is the entire access-control system: two levels, package and exported, with no
Protected, no Friend and no per-class privacy. It has a real consequence for naming — renaming a field changes its visibility — and it means you can tell at a glance, at every use site, whether something crosses a package boundary.Formatting is not a preference
One tool formats every Go file in the world the same way, and the community treats its output as the only correct form.
Option Strict On
Imports System
Module FormatStyleDemo
Sub Main()
' Visual Basic style is a team decision:
' tabs or spaces, brace-free blocks, naming
Dim values() As Integer = {1, 2, 3}
For Each value As Integer In values
Console.Write(value & " ")
Next
Console.WriteLine()
End Sub
End Modulepackage main
import "fmt"
func main() {
// gofmt decides all of it: tabs, alignment, spacing,
// where the braces go. There is no style debate.
values := []int{1, 2, 3}
for _, value := range values {
fmt.Print(value, " ")
}
fmt.Println()
}gofmt ships with the compiler, runs on save in every editor, and is not configurable. Tabs for indentation, a fixed brace position, aligned struct fields. The effect is that all Go code looks alike, code review never discusses layout, and a diff only ever shows a real change. Coming from a language with no such tool this feels authoritarian for about a week and then becomes one of the things you miss elsewhere.Types & Zero Values
Every type has a zero value
Nothing is ever uninitialized, and the value it starts with is chosen to be useful.
Option Strict On
Imports System
Imports System.Collections.Generic
Module ZeroDemo
Sub Main()
Dim number As Integer
Dim text As String
Dim flag As Boolean
Dim items As List(Of Integer)
Console.WriteLine(number)
Console.WriteLine(text Is Nothing)
Console.WriteLine(flag)
Console.WriteLine(items Is Nothing)
End Sub
End Modulepackage main
import "fmt"
type Settings struct {
Retries int
Name string
}
func main() {
var number int
var text string
var flag bool
var settings Settings
fmt.Println(number)
fmt.Printf("%q\n", text)
fmt.Println(flag)
fmt.Printf("%+v\n", settings)
}Numbers start at
0, strings at "", booleans at false, pointers and slices and maps at nil, and a struct at a struct whose every field is its own zero. Note the difference from Visual Basic: an uninitialized String is Nothing there and "" here, so a zero-value Go string can be used immediately. The design goal is that the zero value should be ready to use — a zero sync.Mutex is an unlocked mutex, a zero bytes.Buffer is an empty buffer.Structs replace classes
There are no classes — a type holds data, and methods are attached to it from outside.
Option Strict On
Imports System
Public Class Person
Public ReadOnly Property Name As String
Public ReadOnly Property Age As Integer
Public Sub New(name As String, age As Integer)
Me.Name = name
Me.Age = age
End Sub
Public Function Describe() As String
Return $"{Name}, age {Age}"
End Function
End Class
Module StructDemo
Sub Main()
Dim person As New Person("Ada", 36)
Console.WriteLine(person.Describe())
End Sub
End Modulepackage main
import "fmt"
type Person struct {
Name string
Age int
}
// A method is a function with a receiver, declared OUTSIDE the type
func (p Person) Describe() string {
return fmt.Sprintf("%s, age %d", p.Name, p.Age)
}
// Convention: a constructor is a plain function named New...
func NewPerson(name string, age int) Person {
return Person{Name: name, Age: age}
}
func main() {
person := NewPerson("Ada", 36)
fmt.Println(person.Describe())
fmt.Println(person == NewPerson("Ada", 36))
}A
struct is data only. A method is a function with a receiver — func (p Person) Describe() — written at package level rather than inside the type, which means you can add methods to any type your package defines. There is no constructor: the convention is an ordinary function called NewPerson. And because a struct is a value type compared field by field, == works on it directly, with no Equals to override.Pointers, and when you need one
Go makes explicit the distinction Visual Basic makes by whether you wrote
Class or Structure.Option Strict On
Imports System
Public Class Counter
Public Property Total As Integer
End Class
Public Structure Point
Public X As Integer
End Structure
Module PointerDemo
Sub Increment(counter As Counter)
counter.Total += 1
End Sub
Sub Move(point As Point)
point.X += 1
End Sub
Sub Main()
Dim counter As New Counter()
Increment(counter)
Console.WriteLine(counter.Total)
Dim point As New Point()
Move(point)
Console.WriteLine(point.X)
End Sub
End Modulepackage main
import "fmt"
type Counter struct{ Total int }
// Value receiver: works on a COPY
func (c Counter) IncrementCopy() { c.Total++ }
// Pointer receiver: works on the original
func (c *Counter) Increment() { c.Total++ }
func main() {
counter := Counter{}
counter.IncrementCopy()
fmt.Println(counter.Total)
counter.Increment() // Go takes the address for you
fmt.Println(counter.Total)
pointer := &counter
fmt.Println(pointer.Total)
}Everything is passed by value. A pointer receiver,
*Counter, is what lets a method change the original, and &value takes an address. Go smooths the calling side: counter.Increment() works on a value because the compiler inserts the &. There is no pointer arithmetic and no manual freeing — this is a garbage-collected language — so a pointer here is closer to a .NET reference than to a C one. The rule of thumb: use a pointer receiver if the method mutates or the struct is large, and be consistent within a type.Conversions are always explicit
Stricter than
Option Strict On: even a widening conversion has to be written out.Option Strict On
Imports System
Module ConversionDemo
Sub Main()
Dim whole As Integer = 7
' Option Strict On still WIDENS silently
Dim widened As Double = whole
Dim text As String = CStr(whole)
Dim parsed As Integer = CInt("123")
Console.WriteLine($"{widened} {text} {parsed}")
End Sub
End Modulepackage main
import (
"fmt"
"strconv"
)
func main() {
whole := 7
widened := float64(whole) // required — no implicit widening
text := strconv.Itoa(whole)
parsed, err := strconv.Atoi("123")
if err != nil {
fmt.Println("not a number")
return
}
fmt.Println(widened, text, parsed)
}Go performs no implicit numeric conversion at all, so
int plus float64 is a compile error until you write float64(whole). String conversion is not a cast: strconv.Itoa for int→string, strconv.Atoi for string→int, strconv.FormatFloat and ParseFloat for floats. Note that Atoi returns two values, the result and an error — which is the subject of the errors section and the single biggest difference in this whole comparison.Slices & Maps
Slices replace List(Of T)
The growable list is a language built-in — note that appending returns the new slice rather than changing it in place.
Option Strict On
Imports System
Imports System.Collections.Generic
Module SliceDemo
Sub Main()
Dim fruits As New List(Of String) From {"apple", "banana"}
fruits.Add("cherry")
Console.WriteLine(fruits.Count)
Console.WriteLine(fruits(0))
Console.WriteLine(String.Join(", ", fruits))
End Sub
End Modulepackage main
import (
"fmt"
"strings"
)
func main() {
fruits := []string{"apple", "banana"}
fruits = append(fruits, "cherry")
fmt.Println(len(fruits))
fmt.Println(fruits[0])
fmt.Println(strings.Join(fruits, ", "))
fmt.Println(fruits[1:])
fmt.Println(fruits[:2])
}List(Of String) becomes []string. Add becomes append(slice, item), and you must assign the result: appending may allocate a new backing array, so append(fruits, x) on its own is a bug the compiler will flag as an unused value. Count becomes len(), a built-in function rather than a property. Slicing with [low:high] gives a view onto the same memory, which is fast and — as the gotchas section shows — occasionally surprising.Maps
Reading a missing key does not throw — it hands back the zero value, which is why the two-result form exists.
Option Strict On
Imports System
Imports System.Collections.Generic
Module MapDemo
Sub Main()
Dim ages As New Dictionary(Of String, Integer) From {
{"Ada", 36},
{"Grace", 45}
}
ages("Alan") = 41
Console.WriteLine(ages.Count)
Console.WriteLine(ages("Ada"))
Console.WriteLine(ages.ContainsKey("Nobody"))
Dim found As Integer
ages.TryGetValue("Nobody", found)
Console.WriteLine(found)
End Sub
End Modulepackage main
import (
"fmt"
"sort"
)
func main() {
ages := map[string]int{"Ada": 36, "Grace": 45}
ages["Alan"] = 41
fmt.Println(len(ages))
fmt.Println(ages["Ada"])
fmt.Println(ages["Nobody"]) // 0 — the zero value, not an error
value, ok := ages["Nobody"]
fmt.Println(value, ok)
names := make([]string, 0, len(ages))
for name := range ages {
names = append(names, name)
}
sort.Strings(names) // map order is RANDOM — sort to be deterministic
fmt.Println(names)
}Dictionary(Of K, V) becomes map[K]V, and ContainsKey/TryGetValue both become the comma-ok form: value, ok := ages[key], where ok says whether the key was present. Without it you cannot tell a stored zero from a missing key. The other thing to know: map iteration order is deliberately randomized — it differs between runs of the same program — so any output that must be stable needs the keys collected and sorted, as above.range is how you iterate
One keyword iterates slices, maps, strings, channels and — since Go 1.22 — plain integers.
Option Strict On
Imports System
Imports System.Collections.Generic
Module RangeDemo
Sub Main()
Dim words As New List(Of String) From {"alpha", "beta", "gamma"}
For Each word As String In words
Console.WriteLine(word.ToUpper())
Next
For index As Integer = 0 To words.Count - 1
Console.WriteLine($"{index}: {words(index)}")
Next
End Sub
End Modulepackage main
import (
"fmt"
"strings"
)
func main() {
words := []string{"alpha", "beta", "gamma"}
for _, word := range words {
fmt.Println(strings.ToUpper(word))
}
for index, word := range words {
fmt.Printf("%d: %s\n", index, word)
}
for index := range 3 {
fmt.Print(index, " ")
}
fmt.Println()
}For Each x In items becomes for _, x := range items. range always yields the index first and the value second, which is why the blank identifier appears so often: you usually want only the value. Over a map it yields key and value; over a string it yields byte offsets and runes. for i := range 3 counts 0, 1, 2, which is the closest thing to For i = 0 To 2. Note ToUpper is a package function, strings.ToUpper(word), not a method — strings are a built-in type with no methods of their own.Control Flow
If, and the statement that comes before it
Braces, no parentheses — and an
if may run a statement first, scoping its result to the branches.Option Strict On
Imports System
Module IfDemo
Sub Main()
Dim value As Integer
Dim parsed As Boolean = Integer.TryParse("42", value)
If parsed AndAlso value > 25 Then
Console.WriteLine("Large")
ElseIf parsed Then
Console.WriteLine("Small")
Else
Console.WriteLine("Not a number")
End If
End Sub
End Modulepackage main
import (
"fmt"
"strconv"
)
func main() {
if value, err := strconv.Atoi("42"); err != nil {
fmt.Println("Not a number")
} else if value > 25 {
fmt.Println("Large")
} else {
fmt.Println("Small")
}
}The condition takes no parentheses and the braces are mandatory, even for one statement.
ElseIf becomes else if, and the else must sit on the same line as the closing brace — a consequence of automatic semicolon insertion. The addition is the init statement: if value, err := ...; err != nil declares variables scoped to the whole if/else chain, which is the idiomatic way to handle the two-value returns Go functions produce.Select Case becomes switch
Closer to
Select Case than any C-family switch: no break, and conditions are allowed as cases.Option Strict On
Imports System
Module SelectDemo
Function Describe(code As Integer) As String
Select Case code
Case 1
Return "one"
Case 2, 3
Return "two or three"
Case Is > 100
Return "large"
Case Else
Return "something else"
End Select
End Function
Sub Main()
Console.WriteLine(Describe(1))
Console.WriteLine(Describe(3))
Console.WriteLine(Describe(200))
End Sub
End Modulepackage main
import "fmt"
func describe(code int) string {
switch {
case code == 1:
return "one"
case code == 2, code == 3:
return "two or three"
case code > 100:
return "large"
default:
return "something else"
}
}
func main() {
fmt.Println(describe(1))
fmt.Println(describe(3))
fmt.Println(describe(200))
}Go's
switch does not fall through — each case ends by itself, so no break is needed anywhere, exactly as in Select Case. Several values share a case with a comma, matching Case 2, 3. A switch with no subject takes boolean cases, which is how Case Is > 100 translates. There is an explicit fallthrough keyword for the rare deliberate case, and Case Else becomes default.for is the only loop
Four Visual Basic loop constructs collapse into one keyword wearing three hats.
Option Strict On
Imports System
Module LoopDemo
Sub Main()
For index As Integer = 1 To 5
Console.Write(index & " ")
Next
Console.WriteLine()
Dim remaining As Integer = 3
While remaining > 0
remaining -= 1
End While
Console.WriteLine(remaining)
Dim attempt As Integer = 0
Do
attempt += 1
Loop Until attempt >= 2
Console.WriteLine(attempt)
End Sub
End Modulepackage main
import "fmt"
func main() {
for index := 1; index <= 5; index++ {
fmt.Print(index, " ")
}
fmt.Println()
remaining := 3
for remaining > 0 { // this is "while"
remaining--
}
fmt.Println(remaining)
attempt := 0
for { // this is "do forever"
attempt++
if attempt >= 2 {
break
}
}
fmt.Println(attempt)
}There is no
while and no do: for condition { } is a while loop, for { } loops forever, and for init; cond; step { } is the counted form. To 5 becomes <= 5, the usual off-by-one to check. Exit For becomes break and Continue For becomes continue, both of which accept a label to act on an enclosing loop. There is no Until, so a Loop Until condition gets inverted or becomes a break.There is no If() expression
No ternary, no null-coalescing operator, and no plan to add either.
Option Strict On
Imports System
Module TernaryDemo
Sub Main()
Dim score As Integer = 72
Dim grade As String = If(score >= 60, "pass", "fail")
Dim supplied As String = Nothing
Dim label As String = If(supplied, "(unnamed)")
Console.WriteLine($"{grade} / {label}")
End Sub
End Modulepackage main
import "fmt"
func main() {
score := 72
grade := "fail"
if score >= 60 {
grade = "pass"
}
supplied := ""
label := supplied
if label == "" {
label = "(unnamed)"
}
fmt.Printf("%s / %s\n", grade, label)
}Go has no conditional expression. Both forms of
If() become an if statement assigning to a variable declared just above — which is more lines and, the language designers argue, more readable when the branches grow. There is no ?? and no ?. either. This is representative of Go's whole approach: fewer ways to express a thing, and the one way is usually the plainest.Functions
Sub and Function both become func
One keyword covers both, the types follow the names, and the return type sits after the parameter list.
Option Strict On
Imports System
Module FunctionDemo
Sub Announce(message As String)
Console.WriteLine($"** {message} **")
End Sub
Function Add(left As Integer, right As Integer) As Integer
Return left + right
End Function
Sub Main()
Announce("starting")
Console.WriteLine(Add(2, 3))
End Sub
End Modulepackage main
import "fmt"
func announce(message string) {
fmt.Printf("** %s **\n", message)
}
func add(left, right int) int {
return left + right
}
func main() {
announce("starting")
fmt.Println(add(2, 3))
}Sub becomes a func with no return type; Function ... As Integer becomes func ... int. Parameters sharing a type may share the annotation, as left, right int does. A lower-case name is package-private and a capital one is exported, so announce here is internal. There is no overloading: two functions cannot share a name, so Add(Integer) and Add(Double) become AddInt and AddFloat, or one generic function.Returning more than one thing
The
TryParse shape — a boolean plus a ByRef — becomes ordinary and needs no output parameter.Option Strict On
Imports System
Module MultipleDemo
Function TryDivide(numerator As Integer, denominator As Integer, ByRef result As Integer) As Boolean
If denominator = 0 Then Return False
result = numerator \ denominator
Return True
End Function
Sub Main()
Dim answer As Integer
If TryDivide(10, 2, answer) Then
Console.WriteLine(answer)
End If
End Sub
End Modulepackage main
import "fmt"
func divide(numerator, denominator int) (int, bool) {
if denominator == 0 {
return 0, false
}
return numerator / denominator, true
}
func minimumAndMaximum(values []int) (smallest, largest int) {
smallest, largest = values[0], values[0]
for _, value := range values {
if value < smallest {
smallest = value
}
if value > largest {
largest = value
}
}
return // named results are returned as they stand
}
func main() {
if answer, ok := divide(10, 2); ok {
fmt.Println(answer)
}
low, high := minimumAndMaximum([]int{4, 9, 1, 7})
fmt.Println(low, high)
}A function may return any number of values, listed in parentheses. That removes the need for
ByRef entirely, and it is why strconv.Atoi and every map read hand back two things. Results may be named in the signature, as (smallest, largest int) is, in which case a bare return returns their current values — useful for documentation, and easy to overuse. Assigning several at once, low, high := ..., is the unpacking side.Using becomes defer
Cleanup is registered where the resource is acquired, and runs when the function returns.
Option Strict On
Imports System
Imports System.IO
Module DeferDemo
Sub Main()
Dim contents As String = ""
Using writer As New StringWriter()
writer.WriteLine("first line")
contents = writer.ToString()
End Using
Console.WriteLine(contents.Trim())
End Sub
End Modulepackage main
import (
"fmt"
"strings"
)
func build() string {
var builder strings.Builder
defer fmt.Println("cleanup runs last, whatever happens")
builder.WriteString("first line")
return builder.String()
}
func main() {
fmt.Println(build())
}defer schedules a call for when the surrounding function exits — by return, by falling off the end, or by panic. So Using ... End Using becomes f, err := os.Open(...) immediately followed by defer f.Close(), and the cleanup sits two lines from the acquisition rather than at the far end of a block. Deferred calls run last-in-first-out, and their arguments are evaluated at the moment defer is written, not when it runs.Closures and function values
Functions are values with no delegate type to declare, and they close over the variables around them.
Option Strict On
Imports System
Module ClosureDemo
Sub Main()
Dim twice As Func(Of Integer, Integer) = Function(value) value * 2
Dim total As Integer = 0
Dim accumulate As Action(Of Integer) = Sub(value) total += value
accumulate(5)
accumulate(7)
Console.WriteLine(twice(21))
Console.WriteLine(total)
End Sub
End Modulepackage main
import "fmt"
func makeCounter() func() int {
total := 0
return func() int {
total++
return total
}
}
func main() {
twice := func(value int) int { return value * 2 }
fmt.Println(twice(21))
counter := makeCounter()
fmt.Println(counter())
fmt.Println(counter())
}Func(Of Integer, Integer) becomes the type func(int) int, written inline — there is no Func/Action family and no AddressOf, since a function's name already is a value. A function literal captures the variables in scope, so makeCounter hands back a function with private state, which is Go's answer to a small stateful object. Each call to makeCounter gets its own total.Errors Are Values
There are no exceptions
This is the single largest difference on the page, and it changes the shape of every function you write.
Option Strict On
Imports System
Module ErrorDemo
Function Load(id As Integer) As String
If id < 0 Then Throw New ArgumentException("negative id")
Return $"record {id}"
End Function
Sub Main()
Try
Console.WriteLine(Load(1))
Console.WriteLine(Load(-1))
Catch error_ As ArgumentException
Console.WriteLine($"failed: {error_.Message}")
End Try
End Sub
End Modulepackage main
import (
"errors"
"fmt"
)
func load(id int) (string, error) {
if id < 0 {
return "", errors.New("negative id")
}
return fmt.Sprintf("record %d", id), nil
}
func main() {
record, err := load(1)
if err != nil {
fmt.Println("failed:", err)
return
}
fmt.Println(record)
if _, err := load(-1); err != nil {
fmt.Println("failed:", err)
}
}Go has no exceptions and no
Try/Catch. A function that can fail returns an error as its last result, and the caller checks it — immediately, every time. if err != nil { return err } is the most common three lines in the language, and the verbosity is the deliberate trade: you can see every failure path on the page, and no error can travel silently past a caller who did not think about it. Returning nil for the error means success.Adding context, and matching on it later
Wrapping an error is what
InnerException does, and unwrapping it is a function rather than a cast.Option Strict On
Imports System
Public Class NotFoundException
Inherits Exception
Public Sub New(message As String)
MyBase.New(message)
End Sub
End Class
Module WrapDemo
Function Fetch(id As Integer) As String
Throw New NotFoundException($"no record {id}")
End Function
Sub Main()
Try
Try
Console.WriteLine(Fetch(7))
Catch inner As NotFoundException
Throw New InvalidOperationException("loading failed", inner)
End Try
Catch outer As InvalidOperationException
Console.WriteLine(outer.Message)
Console.WriteLine(TypeOf outer.InnerException Is NotFoundException)
End Try
End Sub
End Modulepackage main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func fetch(id int) (string, error) {
return "", fmt.Errorf("loading record %d: %w", id, ErrNotFound)
}
func main() {
_, err := fetch(7)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrNotFound))
var target *MyError
fmt.Println(errors.As(err, &target))
}
type MyError struct{ Code int }
func (e *MyError) Error() string { return fmt.Sprintf("code %d", e.Code) }fmt.Errorf with the %w verb wraps an error, adding context while keeping the original reachable — the equivalent of passing an inner exception. errors.Is(err, ErrNotFound) then asks "is this, anywhere down the chain, that sentinel error", replacing Catch e As NotFoundException. errors.As is the version that extracts a specific error type so you can read its fields. A package-level ErrSomething value is the idiomatic way to let callers recognize a condition.panic and recover
There is something exception-shaped after all — and the community view is that you should almost never use it.
Option Strict On
Imports System
Module PanicDemo
Sub Main()
Try
Dim numbers() As Integer = {1, 2, 3}
Console.WriteLine(numbers(10))
Catch error_ As IndexOutOfRangeException
Console.WriteLine($"caught: {error_.Message}")
End Try
Console.WriteLine("carrying on")
End Sub
End Modulepackage main
import "fmt"
func risky() (result string, err error) {
defer func() {
if problem := recover(); problem != nil {
err = fmt.Errorf("recovered: %v", problem)
}
}()
numbers := []int{1, 2, 3}
return fmt.Sprint(numbers[10]), nil
}
func main() {
_, err := risky()
fmt.Println(err)
fmt.Println("carrying on")
}panic unwinds the stack running deferred functions, and recover — callable only inside a defer — stops it. So it can be made to behave like Try/Catch, as above. But it is not for ordinary failures: it is for programmer bugs, such as an index out of range, and for a library boundary that must not take the whole process down. A file that does not exist, a malformed number, a failed request — all of those are error values. Reaching for panic where an error belongs is the most common way ported .NET code reads wrongly in Go.Methods & Interfaces
Interfaces are satisfied, not declared
Nothing says that
Formal implements Greeter — it has the method, so it does.Option Strict On
Imports System
Imports System.Collections.Generic
Public Interface IGreeter
Function Greet(name As String) As String
End Interface
Public Class Formal
Implements IGreeter
Public Function Greet(name As String) As String Implements IGreeter.Greet
Return $"Good day, {name}."
End Function
End Class
Module InterfaceDemo
Sub Main()
Dim greeters As New List(Of IGreeter) From {New Formal()}
For Each greeter As IGreeter In greeters
Console.WriteLine(greeter.Greet("Ada"))
Next
End Sub
End Modulepackage main
import "fmt"
type Greeter interface {
Greet(name string) string
}
type Formal struct{}
// No "implements" clause anywhere
func (Formal) Greet(name string) string {
return "Good day, " + name + "."
}
func main() {
greeters := []Greeter{Formal{}}
for _, greeter := range greeters {
fmt.Println(greeter.Greet("Ada"))
}
}Go interfaces are structural: a type satisfies an interface by having the right methods, with no declaration on either side. The consequence that matters is that the interface can be defined by the consumer, after the fact, for a type it does not own — including types from someone else's package. Go convention keeps interfaces small (one or two methods, often named
-er: Reader, Writer, Stringer) and defines them where they are used rather than where the implementations live.There is no inheritance
The nearest thing to a base class is a field with no name — and it is composition, not inheritance.
Option Strict On
Imports System
Public Class Animal
Protected ReadOnly Name As String
Public Sub New(name As String)
Me.Name = name
End Sub
Public Overridable Function Speak() As String
Return $"{Name} makes a sound"
End Function
End Class
Public Class Dog
Inherits Animal
Public Sub New(name As String)
MyBase.New(name)
End Sub
Public Overrides Function Speak() As String
Return $"{Name} barks"
End Function
End Class
Module InheritanceDemo
Sub Main()
Dim pets As Animal() = {New Dog("Rex")}
For Each pet As Animal In pets
Console.WriteLine(pet.Speak())
Next
End Sub
End Modulepackage main
import "fmt"
type Animal struct {
Name string
}
func (a Animal) Speak() string { return a.Name + " makes a sound" }
func (a Animal) Describe() string { return "an animal called " + a.Name }
type Dog struct {
Animal // embedded: Dog gets Animal's methods
}
func (d Dog) Speak() string { return d.Name + " barks" }
func main() {
dog := Dog{Animal{Name: "Rex"}}
fmt.Println(dog.Speak())
fmt.Println(dog.Describe())
fmt.Println(dog.Animal.Speak())
}Go has no inheritance, no
Inherits, no Overridable, no MyBase. Embedding a type puts its fields and methods on the outer type, so Dog answers Describe for free and can define its own Speak to shadow the inner one. The crucial difference from overriding: this is not polymorphic — a method on Animal calling Speak gets Animal's, never Dog's. Polymorphism comes only from interfaces, which is why Go code is designed around them.ToString becomes String()
Overriding
ToString becomes satisfying a one-method interface — and nothing has to be declared for it to take effect.Option Strict On
Imports System
Public Structure Money
Public ReadOnly Amount As Decimal
Public ReadOnly Currency As String
Public Sub New(amount As Decimal, currency As String)
Me.Amount = amount
Me.Currency = currency
End Sub
Public Overrides Function ToString() As String
Return $"{Amount:F2} {Currency}"
End Function
End Structure
Module StringerDemo
Sub Main()
Console.WriteLine(New Money(9.99D, "USD"))
End Sub
End Modulepackage main
import "fmt"
type Money struct {
Amount float64
Currency string
}
func (m Money) String() string {
return fmt.Sprintf("%.2f %s", m.Amount, m.Currency)
}
func main() {
money := Money{9.99, "USD"}
fmt.Println(money)
fmt.Printf("%v | %+v\n", money, struct{ A int }{1})
}Defining
String() string makes a type satisfy fmt.Stringer, and every printing function uses it automatically. That is the structural-interface idea doing real work: no Overrides, no base class, just a method with the right name. Note also that Go has no Decimal: float64 is the practical default and is wrong for money, so a real accounting service uses integer minor units or math/big's Rat.Goroutines & Channels
Goroutines
One keyword starts concurrent work, and there is no
Task object handed back to wait on.Option Strict On
Imports System
Imports System.Threading.Tasks
Module TaskDemo
Async Function WorkAsync(label As String) As Task(Of String)
Await Task.Delay(1)
Return $"done {label}"
End Function
Sub Main()
Dim results = Task.WhenAll(WorkAsync("one"), WorkAsync("two")).
GetAwaiter().GetResult()
For Each value As String In results
Console.WriteLine(value)
Next
End Sub
End Modulepackage main
import (
"fmt"
"sort"
"sync"
)
func main() {
var waiter sync.WaitGroup
var mutex sync.Mutex
results := []string{}
for _, label := range []string{"one", "two"} {
waiter.Add(1)
go func() {
defer waiter.Done()
mutex.Lock()
results = append(results, "done "+label)
mutex.Unlock()
}()
}
waiter.Wait()
sort.Strings(results)
for _, value := range results {
fmt.Println(value)
}
}go f() runs f concurrently. A goroutine is far cheaper than a thread — thousands are routine — but it returns nothing, so there is no Task to await. Coordination is separate: sync.WaitGroup waits for a set of them to finish, and sync.Mutex guards shared state, exactly as it would in .NET. Note that unlike JavaScript this is real parallelism across cores, so a data race is possible and the mutex is not decorative.Channels
A typed pipe between goroutines, built into the language rather than supplied by a library.
Option Strict On
Imports System
Imports System.Collections.Concurrent
Imports System.Threading.Tasks
Module ChannelDemo
Sub Main()
Dim queue As New BlockingCollection(Of Integer)()
Task.Run(Sub()
For number As Integer = 1 To 3
queue.Add(number)
Next
queue.CompleteAdding()
End Sub).Wait()
For Each value As Integer In queue.GetConsumingEnumerable()
Console.WriteLine(value)
Next
End Sub
End Modulepackage main
import "fmt"
func produce(out chan<- int) {
for number := 1; number <= 3; number++ {
out <- number
}
close(out)
}
func main() {
numbers := make(chan int)
go produce(numbers)
for value := range numbers {
fmt.Println(value)
}
done := make(chan string, 1)
done <- "finished"
fmt.Println(<-done)
}A
chan int carries values between goroutines; out <- value sends and <-channel receives. An unbuffered channel blocks until the other side is ready, which makes it a synchronization primitive as well as a queue. close ends the stream, and for value := range channel reads until it closes — the counterpart of GetConsumingEnumerable. chan<- int in a signature means send-only, which the compiler enforces. The community slogan is "share memory by communicating", meaning prefer a channel to a mutex where you can.Waiting on several things, and cancelling
select waits on whichever channel is ready first — and context is how cancellation travels through a service.Option Strict On
Imports System
Imports System.Threading
Imports System.Threading.Tasks
Module CancelDemo
Sub Main()
Dim source As New CancellationTokenSource()
Dim token As CancellationToken = source.Token
source.Cancel()
Console.WriteLine(token.IsCancellationRequested)
End Sub
End Modulepackage main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
results := make(chan string, 1)
go func() { results <- "work finished" }()
select {
case value := <-results:
fmt.Println(value)
case <-ctx.Done():
fmt.Println("cancelled")
}
cancel()
<-ctx.Done()
fmt.Println(ctx.Err())
}select is a switch over channel operations: it blocks until one is ready and takes that branch. Combined with a timeout channel it gives you "whichever happens first", which is Task.WhenAny. context.Context is the direct counterpart of CancellationToken, and Go convention passes it as the first parameter of every function that can block — you will see ctx context.Context throughout any server codebase. It carries deadlines and cancellation down the call tree.Packages, Modules & Tooling
Imports becomes import
Imports name a package and you always reach through it — there is no way to pull a name into bare scope.
Option Strict On
Imports System
Imports Shortcut = System.Math
Module ImportDemo
Sub Main()
Console.WriteLine(String.Join(", ", New String() {"a", "b"}))
Console.WriteLine(Shortcut.Max(3, 7))
Console.WriteLine("go".ToUpper())
End Sub
End Modulepackage main
import (
"fmt"
"math"
"strings"
)
func main() {
fmt.Println(strings.Join([]string{"a", "b"}, ", "))
fmt.Println(math.Max(3, 7))
fmt.Println(strings.ToUpper("go"))
}An import brings the package into scope, and its members are always qualified:
strings.Join, never a bare Join. There is no equivalent of Imports System.Math pulling names into bare scope, and no way to shorten a call — which is deliberate, since every call then says where it came from. Aliasing exists (str "strings") and is used mainly to resolve a name collision, not for brevity. Remember that an unused import is a compile error.NuGet becomes modules, and the output is one file
The packaging story maps across, and the last two lines are the reason people choose Go for services.
Option Strict On
Imports System
Imports System.Collections.Generic
Module DeployDemo
Sub Main()
Dim story As New Dictionary(Of String, String) From {
{"manifest", ".vbproj"},
{"registry", "NuGet"},
{"restore", "dotnet restore"},
{"output", "dll plus a runtime"}
}
For Each entry In story
Console.WriteLine($"{entry.Key}: {entry.Value}")
Next
End Sub
End Modulepackage main
import "fmt"
func main() {
story := [][2]string{
{"manifest", "go.mod"},
{"registry", "proxy.golang.org"},
{"fetch", "go mod tidy"},
{"output", "one static binary, no runtime"},
{"test", "go test ./... — built in"},
}
for _, entry := range story {
fmt.Printf("%s: %s\n", entry[0], entry[1])
}
}go.mod is the .vbproj, go mod tidy is dotnet restore, and modules are fetched by URL rather than from a central registry with an account. The differences worth the move: go build produces one statically linked binary with no runtime to install — you copy a file to the server and run it, which is a materially smaller deployment story than shipping .NET. And go test, go vet and gofmt are part of the toolchain, so a project needs no test framework, no linter package and no formatter argument.⚠ Gotchas for Visual Basic Programmers
⚠ / truncates between integers
The familiar trap, made slightly less dangerous by Go's refusal to convert anything for you.
Option Strict On
Imports System
Module DivisionGotcha
Sub Main()
Dim average As Double = (3 + 4) / 2
Console.WriteLine(average)
End Sub
End Modulepackage main
import "fmt"
func main() {
average := (3 + 4) / 2
fmt.Println(average)
correct := float64(3+4) / 2.0
fmt.Println(correct)
}Go has no
\, and / between two integers truncates — so a Visual Basic / copied across silently starts doing integer division. What limits the damage is that Go performs no implicit conversion: writing (3 + 4) / 2.0 is a compile error rather than a wrong answer, because an untyped integer expression cannot mix with a float variable of the wrong type. Being made to write float64(...) is the prompt to think about it.⚠ A nil map reads fine and explodes on write
The asymmetry is the trap: reading a nil map is legal, so the bug hides until something writes.
Option Strict On
Imports System
Imports System.Collections.Generic
Module NilMapGotcha
Sub Main()
Dim ages As Dictionary(Of String, Integer) = Nothing
Try
Console.WriteLine(ages("Ada"))
Catch error_ As NullReferenceException
Console.WriteLine("reading a Nothing dictionary throws")
End Try
End Sub
End Modulepackage main
import "fmt"
func main() {
var ages map[string]int // nil, not empty
fmt.Println(ages["Ada"]) // 0 — reading is FINE
fmt.Println(len(ages)) // 0 — fine too
// ages["Ada"] = 36 // PANIC: assignment to entry in nil map
ages = make(map[string]int) // or map[string]int{}
ages["Ada"] = 36
fmt.Println(ages["Ada"])
}A declared-but-not-initialized map is
nil. Reading from it returns the zero value and len is 0, so code that only reads works perfectly. Writing to it panics at runtime. A nil map must be created with make(map[K]V) or a literal before any assignment. A nil slice, by contrast, is genuinely usable — append to a nil slice works and returns a real one — which makes the map behaviour easier to forget.⚠ A slice is a view onto shared memory
Slicing looks like
Skip/Take and does something quite different.Option Strict On
Imports System
Imports System.Collections.Generic
Imports System.Linq
Module SliceGotcha
Sub Main()
Dim original As New List(Of Integer) From {1, 2, 3, 4}
' Every one of these makes a COPY
Dim part As List(Of Integer) = original.Skip(1).Take(2).ToList()
part(0) = 99
Console.WriteLine(String.Join(", ", original))
Console.WriteLine(String.Join(", ", part))
End Sub
End Modulepackage main
import "fmt"
func main() {
original := []int{1, 2, 3, 4}
part := original[1:3] // a VIEW, not a copy
part[0] = 99
fmt.Println(original)
fmt.Println(part)
safe := make([]int, 2)
copy(safe, original[1:3])
safe[0] = 7
fmt.Println(original, safe)
}A slice is a length, a capacity and a pointer into a backing array. Slicing does not copy:
original[1:3] shares memory with original, so writing through one is visible in the other. LINQ's Skip/Take always produce independent sequences, so this catches people constantly. When you need a real copy, allocate one and use the built-in copy. The same sharing means append can sometimes write into a neighbouring slice's memory and sometimes reallocate, depending on capacity.⚠ No overloading, no optional arguments
Two features you use without thinking are simply absent, and the idioms that replace them are worth learning early.
Option Strict On
Imports System
Module OverloadGotcha
Function Greet(name As String,
Optional greeting As String = "Hello") As String
Return $"{greeting}, {name}"
End Function
Function Area(side As Double) As Double
Return side * side
End Function
Function Area(width As Double, height As Double) As Double
Return width * height
End Function
Sub Main()
Console.WriteLine(Greet("Ada"))
Console.WriteLine(Area(3))
Console.WriteLine(Area(3, 4))
End Sub
End Modulepackage main
import "fmt"
type GreetOptions struct {
Greeting string
}
func Greet(name string, options GreetOptions) string {
if options.Greeting == "" {
options.Greeting = "Hello" // the zero value means "unset"
}
return options.Greeting + ", " + name
}
func AreaSquare(side float64) float64 { return side * side }
func AreaRectangle(width, height float64) float64 { return width * height }
func main() {
fmt.Println(Greet("Ada", GreetOptions{}))
fmt.Println(AreaSquare(3))
fmt.Println(AreaRectangle(3, 4))
}Go has no overloading, no optional parameters and no named arguments. Two functions cannot share a name, so
Area becomes AreaSquare and AreaRectangle — which the community regards as clearer, since the name now says which one you meant. Optional settings become an options struct, where the zero value stands for "not supplied"; the other common pattern is functional options, a variadic list of configuring functions. Both are more ceremony than Optional, and both survive the addition of a fourth option better.⚠ No My namespace, and no desktop story
Everything has a counterpart in the standard library — but the shape of the application changes completely.
Option Strict On
Imports System
Module PlatformGotcha
Sub Main()
Console.WriteLine(Environment.MachineName.Length > 0)
Console.WriteLine(IsNumeric("42"))
Console.WriteLine(Now.Year > 2000)
End Sub
End Modulepackage main
import (
"fmt"
"os"
"strconv"
"time"
)
func main() {
name, _ := os.Hostname()
fmt.Println(len(name) >= 0)
_, err := strconv.Atoi("42")
fmt.Println(err == nil)
fmt.Println(time.Now().Year() > 2000)
}My.Computer.Name becomes os.Hostname(), My.Computer.FileSystem becomes os and path/filepath, IsNumeric becomes strconv.Atoi and an error check, Now becomes time.Now(). The larger point: Go has no desktop GUI story and no designer. A WinForms application does not port — it becomes an HTTP service (net/http is in the standard library and is genuinely production-grade) with a web front end, or a command-line tool. That is the decision to weigh before any of the syntax on this page matters.