Syntax Basics
Hello, World / Program Structure
puts "Hello, World!" package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
} Every Go source file begins with a
package declaration. Executables must be in package main with a func main() entry point. The opening brace must be on the same line as the declaration β the compiler enforces this via automatic semicolon insertion. There is no script mode.Comments
# single line
=begin
multi-line block
=end func Add(a, b int) int {
return a + b
}
// Single-line comment
/*
Multi-line block comment.
Used for package-level docs.
*/
// GoDoc reads // comments above a declaration:
// Add returns the sum of a and b.
fmt.Println(Add(2, 3)) // comments don't affect execution Go uses C-style comments. The
// style is standard for all line comments; /* */ for block docs. The go doc tool reads // comments immediately preceding a declaration. The comment should begin with the name being documented: // Add returnsβ¦ not // This addsβ¦Tab Indentation
# Ruby uses 2-space indentation everywhere β a convention, not a runtime
# rule. The same spaces serve for alignment within a line as well;
# Ruby makes no distinction between indentation and alignment characters.
def launch(host:, port:, workers:)
raise ArgumentError, 'need at least one worker' if workers < 1
"#{host}:#{port} with #{workers} workers"
end
puts launch(host: 'localhost', port: 8080, workers: 4) type Server struct {
Host string
Port int
Workers int
}
func launch(server Server) string {
if server.Workers < 1 {
return "need at least one worker"
}
return fmt.Sprintf("%s:%d with %d workers",
server.Host, server.Port, server.Workers)
}
fmt.Println(launch(Server{Host: "localhost", Port: 8080, Workers: 4})) Go mandates a tab character (
) per indentation level β gofmt enforces this with no configuration. The tab width is a display setting: the Go spec assumes 8-character tab stops, but 4 is standard in most editors. Because every indent is always a single , the code is structurally identical at any display width. Within a line, spaces handle alignment (the struct field types are padded with spaces, not tabs), so column-aligned code stays stable at any tab width. gofmt applies the tabs-for-indentation, spaces-for-alignment rule automatically.tab display width
Formatting β gofmt
# RuboCop enforces a style guide
# Teams debate settings, indentation,
# quote style, line length, etc. type Rectangle struct{ Width,Height float64 }
func (r Rectangle) Area() float64{return r.Width*r.Height}
func (r Rectangle) Perimeter() float64{return 2*(r.Width+r.Height)}
small:=Rectangle{Width:3,Height:4}
large:=Rectangle{Width:10.5,Height:7.2}
fmt.Printf("small: area=%.1f perimeter=%.1f\n",small.Area(),small.Perimeter())
fmt.Printf("large: area=%.1f perimeter=%.1f\n",large.Area(),large.Perimeter()) gofmt rewrites Go source to a single canonical style β specific spacing, aligned fields, standardized braces. The example on the left is valid but deliberately badly formatted. Click βΆ Run to see it reformatted instantly into canonical gofmt style. There are no style debates equivalent to RuboCop configurations; all Go code in the wild looks identical.Variables & Types
Declaration
name = "Alice" # inferred type
count = 0
LIMIT = 100 # constant (convention only) var greeting string = "Hello"
var total int = 0
const Limit = 100
const Pi = 3.14159265358979
// Short declaration β infers type; only inside functions
name := "Alice"
count := 0
// Long form β valid at package level too
// Multiple assignment
x, y := 10, 20
// Constants β immutable, evaluated at compile time
fmt.Println(name, count, greeting, total, x, y, Limit, Pi) := is the short variable declaration β it infers the type and is valid only inside function bodies. var works at both package and function scope. Every declared local variable that is never used is a compile error β not a warning. Constants are evaluated at compile time and cannot have their address taken.Zero Values
class Thing
def show_defaults
puts @count.inspect # => nil
puts @name.inspect # => nil
end
end
Thing.new.show_defaults var count int // 0
var ratio float64 // 0.0
var active bool // false
var name string // "" (not nil!)
var pointer *int // nil
var numbers []int // nil slice
var scores map[string]int // nil map
var channel chan int // nil channel
fmt.Println(count, ratio, active)
fmt.Printf("%q\n", name) // ""
fmt.Println(pointer, numbers, scores, channel) Every type in Go has a zero value β what a variable holds when declared but not assigned. This eliminates uninitialized-memory bugs. Crucially,
string's zero value is "", not nil β strings are value types. Only reference types (pointers, slices, maps, channels, functions, interfaces) have a zero value of nil.Constants & iota
MAX_SIZE = 1024
module Weekday
SUNDAY = 0
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
SATURDAY = 6
end
puts Weekday::MONDAY const MaxSize = 1024
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
type Permission uint
const (
Read Permission = 1 << iota // 1
Write // 2
Execute // 4
)
// iota in expressions β powers of two (bitmasks)
fmt.Println(Sunday, Monday, Saturday) // 0 1 6
fmt.Println(Read, Write, Execute) // 1 2 4
fmt.Printf("MaxSize = %d\n", MaxSize) iota resets to 0 at the start of each const block and increments by one per constant. It can appear in expressions β 1 << iota generates powers of two, the standard bitmask pattern. Use _ = iota to skip a value. There is no Ruby equivalent; the closest idiom is frozen symbol constants in a module.Numeric Types
puts 42.class # => Integer
puts 3.14.class # => Float
puts (10 / 3) # => 3
puts (10.0 / 3) # => 3.333... var age int = 42
var ratio float64 = 3.14
var count int = 10
// int, int8, int16, int32, int64
// uint, uint8 (byte), uint16, uint32, uint64
// float32, float64
// complex64, complex128, rune (int32), byte (uint8)
// No implicit conversion β ever
converted := float64(count) / 3.0
fmt.Println(10 / 3) // 3
fmt.Println(10.0 / 3.0) // 3.333...
fmt.Println(age, ratio, converted) Go has many distinct numeric types β you must choose explicitly.
int and float64 are the right defaults. There is no implicit type coercion anywhere β adding an int and a float64 is a compile error. Every conversion must be explicit: float64(count). This eliminates a whole class of Ruby numeric surprises.Strings
String Operations
greeting = "Hello, World!"
puts greeting.length # => 13
puts greeting.upcase # => "HELLO, WORLD!"
puts greeting.include?("World") # => true
puts greeting[0..4] # => "Hello"
puts greeting.gsub("World","Go") # => "Hello, Go!"
puts greeting.split(", ") # => ["Hello","World!"] greeting := "Hello, World!"
fmt.Println(len(greeting)) // 13 (bytes!)
fmt.Println(strings.ToUpper(greeting)) // HELLO, WORLD!
fmt.Println(strings.Contains(greeting, "World")) // true
fmt.Println(greeting[0:5]) // Hello
fmt.Println(strings.ReplaceAll(greeting, "World", "Go")) // Hello, Go!
fmt.Println(strings.Split(greeting, ", ")) // [Hello World!]
fmt.Println(strings.HasPrefix(greeting, "Hello")) // true
fmt.Println(strings.TrimSpace(" hello ")) // hello Unlike Ruby's method syntax, Go string operations are package-level functions from the
strings package β the string type itself has no methods. len(string) returns bytes, not characters. A UTF-8 multi-byte character like Γ© is 2 bytes; π is 4 bytes. Use []rune(str) for Unicode-correct character counting.String Formatting (fmt)
name = "Alice"
age = 30
puts "Hello, #{name}! You are #{age} years old."
puts "Pi is approximately %.2f" % Math::PI
message = "Balance: $%.2f" % 1234.56
puts message name := "Alice"
age := 30
fmt.Printf("Hello, %s! You are %d years old.\n", name, age)
message := fmt.Sprintf("Pi is approximately %.2f", 3.14159)
fmt.Println(message)
// Common verbs:
// %s string %d integer %f float
// %v default %T type name %q quoted string
// %+v struct with field names %b binary
person := struct {
Name string
Age int
}{"Bob", 25}
fmt.Printf("%v\n", person) // {Bob 25}
fmt.Printf("%+v\n", person) // {Name:Bob Age:25} Go has no string interpolation β no backtick templates or
#{}. The fmt package uses C-style format verbs. %v is the workhorse β it formats any value using its default representation. Use Sprintf to build strings; Printf to print; Fprintf to write to an io.Writer.Runes & Unicode
emoji = "Hello π"
puts emoji.length # => 7 (characters)
puts emoji.bytesize # => 10 (bytes)
puts emoji.chars.last # => "π"
emoji.each_char { |character| print "#{character} " }
puts emoji := "Hello π"
fmt.Println(len(emoji)) // 10 (bytes!)
fmt.Println(len([]rune(emoji))) // 7 (code points)
// range iterates by rune β Unicode-correct
for byteIndex, character := range emoji {
fmt.Printf("byte %d: %c\n", byteIndex, character)
}
// Character-position indexing β convert to []rune first
runes := []rune(emoji)
fmt.Printf("Last: %c\n", runes[len(runes)-1]) // π The
range loop over a string iterates by rune (Unicode code point), not byte β each iteration gives the byte offset and the rune value. The byte offset may jump by more than 1 for multi-byte characters. Converting to []rune gives a slice indexed by character position, equivalent to Ruby's String#chars. The rune type is an alias for int32.Collections
Arrays (Fixed-Size β Rarely Used Directly)
# Ruby arrays are always dynamic
numbers = [1, 2, 3, 4, 5]
puts numbers.length var numbers [5]int = [5]int{1, 2, 3, 4, 5}
// The size is part of the type β [3]int β [5]int
names := [3]string{"Alice", "Bob", "Carol"}
inferred := [...]int{10, 20, 30} // compiler counts
fmt.Println(numbers)
fmt.Println(len(names)) // 3
fmt.Println(inferred)
// Arrays are values β assignment copies all elements
copy1 := numbers
copy1[0] = 99
fmt.Println(numbers[0], copy1[0]) // 1 99 Go arrays have a fixed length that is part of the type β
[3]int and [5]int are incompatible types. They are value types: assignment copies all elements. In practice, Go programmers almost always use slices instead. Arrays exist primarily as backing storage for slices. The [...]T syntax lets the compiler count elements.Slices (Dynamic Arrays β Ruby Arrays)
numbers = [1, 2, 3, 4]
numbers << 5
puts numbers[1..3].inspect # => [2, 3, 4]
puts numbers.first # => 1
puts numbers.last # => 5
numbers2 = numbers.dup
numbers2 << 99
puts numbers.inspect # unaffected by push to copy numbers := []int{1, 2, 3}
// append ALWAYS returns a new slice β must assign back!
numbers = append(numbers, 4)
numbers = append(numbers, 5)
fmt.Println(len(numbers)) // 5
fmt.Println(cap(numbers)) // capacity (may vary)
fmt.Println(numbers[1:4]) // [2 3 4]
fmt.Println(numbers[0]) // 1
fmt.Println(numbers[len(numbers)-1]) // 5
// make β preallocate for performance
buffer := make([]int, 0, 128)
// Independent copy β slices share backing arrays (see Gotchas)
original := []int{1, 2, 3, 4, 5}
duplicate := make([]int, len(original))
copy(duplicate, original)
fmt.Println(buffer, duplicate) Slices are the Go equivalent of Ruby arrays. A slice has three internal fields: a pointer to a backing array, a length, and a capacity.
append returns a new slice header β you must assign the result back (numbers = append(numbers, 4)). Forgetting this is a common bug. Slicing a slice shares the backing array β see the Gotchas section.Maps (β Ruby Hashes)
scores = { "Alice" => 95, "Bob" => 87, "Carol" => 92 }
scores["David"] = 78
puts scores["Alice"] # => 95
puts scores.key?("Eve") # => false
scores.delete("Bob")
scores.each { |name, score| puts "#{name}: #{score}" } scores := map[string]int{
"Alice": 95,
"Bob": 87,
"Carol": 92,
}
scores["David"] = 78
fmt.Println(scores["Alice"]) // 95
// Two-value lookup β ALWAYS use this to distinguish missing from zero
score, exists := scores["Eve"]
fmt.Println(score, exists) // 0 false
delete(scores, "Bob")
for name, score := range scores {
fmt.Printf("%s: %d\n", name, score)
} The "comma-ok" pattern (
value, ok := map[key]) is essential β a missing key returns the zero value of the value type (0 for int, "" for string), not nil. Without the ok check, a missing key and a stored 0 are indistinguishable. Maps are not safe for concurrent reads and writes β use sync.Map or a sync.RWMutex.Control Flow
if / else
temperature = 72
if temperature > 80
puts "hot"
elsif temperature > 60
puts "comfortable"
else
puts "cold"
end
# Guard clause / one-liner
puts "freezing" if temperature temperature := 72
if temperature > 80 {
fmt.Println("hot")
} else if temperature > 60 {
fmt.Println("comfortable")
} else {
fmt.Println("cold")
}
// Init statement β variable scoped to the if/else block
// This is the idiomatic error-check pattern:
if err := fmt.Errorf("demo error"); err != nil {
fmt.Println("error:", err)
} Go
if always requires braces β no one-liners, no unless. Parentheses around the condition are not required and gofmt removes them. The init statement (if value := expr; condition) is idiomatic for error checks and scopes the variable to the if/else block. There is no ternary operator.Functions
Basic Functions
def add(a, b)
a + b # implicit return
end
def square(n) = n * n # one-liner (Ruby 4.0)
puts add(3, 4) # => 7
puts square(5) # => 25 func add(a int, b int) int {
return a + b // explicit return always required
}
func addShort(a, b int) int {
return a + b
}
func square(n int) int {
return n * n
}
// Consecutive same-type params can share the type
fmt.Println(add(3, 4)) // 7
fmt.Println(square(5)) // 25 Go functions always require explicit
return β there is no implicit last-expression return. Parameter types follow the parameter name (name type, not type name). When consecutive parameters share a type, write a, b int. Functions are first-class values. The return type comes after the parameter list.Multiple Return Values
def divmod_pair(a, b)
[a / b, a % b] # returns an array
end
quotient, remainder = divmod_pair(17, 5)
puts quotient # => 3
puts remainder # => 2 func divmod(a, b int) (int, int) {
return a / b, a % b
}
func safeDivide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
quotient, remainder := divmod(17, 5)
fmt.Println(quotient, remainder) // 3 2
// The dominant pattern: (result, error)
result, err := safeDivide(10.0, 3.0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Printf("%.4f\n", result) // 3.3333
}
// Discard a return value with _
_, err2 := safeDivide(5.0, 0)
fmt.Println(err2) Multiple return values are Go's idiomatic replacement for Ruby's array returns, exceptions, and
rescue. The (result, error) pattern is universal β callers are forced to acknowledge failures. Use _ to discard a return value you don't need. Named return values (e.g., (result float64, err error)) document intent but naked return hurts readability in longer functions.Variadic Functions
def sum(*numbers)
numbers.sum
end
puts sum(1, 2, 3, 4, 5) # => 15
values = [1, 2, 3]
puts sum(*values) # => 6 func sum(numbers ...int) int {
total := 0
for _, number := range numbers {
total += number
}
return total
}
fmt.Println(sum(1, 2, 3, 4, 5)) // 15
values := []int{1, 2, 3}
fmt.Println(sum(values...)) // 6 β spread with ... Variadic parameters (
...T) collect extra arguments into a slice β equivalent to Ruby's splat (*args). A variadic parameter must be the last parameter. To pass a slice to a variadic function, spread it with ...: sum(values...). Unlike Ruby, Go allows only one variadic parameter per function, and it must collect values of the same type.Closures & First-Class Functions
def multiplier(factor)
->(n) { n * factor }
end
double = multiplier(2)
triple = multiplier(3)
puts double.(5) # => 10
puts triple.(5) # => 15
[1, 2, 3].map { |number| double.(number) }.each { |n| puts n } type Transformer func(int) int
func multiplier(factor int) Transformer {
return func(n int) int {
return n * factor // captures factor from enclosing scope
}
}
double := multiplier(2)
triple := multiplier(3)
fmt.Println(double(5)) // 10
fmt.Println(triple(5)) // 15
apply := func(numbers []int, transform Transformer) []int {
result := make([]int, len(numbers))
for index, number := range numbers {
result[index] = transform(number)
}
return result
}
fmt.Println(apply([]int{1, 2, 3}, double)) // [2 4 6] Go closures capture variables by reference β the closure sees the variable itself, not a snapshot of its value at creation time. This matches Ruby's block/lambda behaviour. A common goroutine gotcha: capturing a loop variable captures the variable (which keeps changing), not its current value. Fix: assign to a local variable inside the loop before spawning the goroutine.
Structs & Methods
Structs (β Ruby Classes)
class Person
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
def introduce = "I'm #{@name}, age #{@age}"
end
person = Person.new("Alice", 30)
puts person.introduce
puts person.name type Person struct {
Name string // Uppercase = exported (public)
Age int // Lowercase = unexported (package-private)
}
func (person Person) Introduce() string {
return fmt.Sprintf("I'm %s, age %d", person.Name, person.Age)
}
func NewPerson(name string, age int) Person {
return Person{Name: name, Age: age}
}
person := NewPerson("Alice", 30)
fmt.Println(person.Introduce())
fmt.Println(person.Name)
// Named-field struct literal (order-independent)
another := Person{Name: "Bob", Age: 25}
fmt.Println(another) Go has no classes β structs with methods are the equivalent. Exported identifiers (fields, methods, types, functions) begin with an uppercase letter; unexported (private to the package) begin with lowercase. Methods are defined outside the struct body, linked by a receiver. The receiver is named by convention with a 1β2 letter abbreviation of the type. There is no
self keyword.Pointer Receivers & Mutation
class Counter
attr_reader :count
def initialize = @count = 0
def increment = @count += 1
def reset = @count = 0
end
counter = Counter.new
counter.increment
counter.increment
puts counter.count # => 2
counter.reset
puts counter.count # => 0 type Counter struct{ count int }
func (counter *Counter) Increment() { counter.count++ }
func (counter *Counter) Reset() { counter.count = 0 }
func (counter Counter) Count() int { return counter.count }
// Pointer receiver β can modify the struct
// Value receiver β receives a copy; cannot modify the original
counter := &Counter{} // pointer to zero-valued Counter
counter.Increment()
counter.Increment()
fmt.Println(counter.Count()) // 2
counter.Reset()
fmt.Println(counter.Count()) // 0 Use pointer receivers (
*T) when a method needs to modify the struct, or when the struct is large enough that copying is expensive. Use value receivers when the method only reads and the struct is small. Consistency matters: if any method uses a pointer receiver, all methods should. Go automatically takes the address when you call a pointer-receiver method on an addressable value.fmt.Stringer β Go's to_s
class Point
def initialize(x, y)
@x = x
@y = y
end
def to_s = "(#{@x}, #{@y})"
def inspect = "Point(#{@x}, #{@y})"
end
point = Point.new(3, 4)
puts point # => (3, 4)
puts point.inspect # => Point(3, 4) type Point struct{ X, Y int }
func (point Point) String() string {
return fmt.Sprintf("(%d, %d)", point.X, point.Y)
}
// Implement fmt.Stringer: String() string
point := Point{X: 3, Y: 4}
fmt.Println(point) // (3, 4) β calls String()
fmt.Printf("Point: %v\n", point) // Point: (3, 4)
fmt.Printf("Raw: %+v\n", point) // Raw: {X:3 Y:4} The
fmt.Stringer interface (String() string) is Go's equivalent of Ruby's to_s. Any type implementing it controls how fmt.Println and %v/%s display it. The %+v verb bypasses Stringer and prints raw struct fields β useful for debugging. fmt.GoStringer (GoString() string) maps to Ruby's inspect.Embedding (Composition β Ruby Mixins)
module Greetable
def greet = "Hello, I'm #{name}"
end
class Employee
include Greetable
attr_reader :name, :department
def initialize(name, department)
@name = name
@department = department
end
end
puts Employee.new("Alice", "Engineering").greet type Named struct{ Name string }
func (named Named) Greet() string {
return fmt.Sprintf("Hello, I'm %s", named.Name)
}
type Employee struct {
Named // embedded β fields & methods promoted
Department string
}
employee := Employee{
Named: Named{Name: "Alice"},
Department: "Engineering",
}
// Promoted field and method β accessed directly on Employee
fmt.Println(employee.Name) // Alice
fmt.Println(employee.Greet()) // Hello, I'm Alice
fmt.Println(employee.Department) Embedding is Go's composition mechanism β the embedded type's fields and methods are promoted to the outer struct. This is not inheritance: no polymorphism, no
super, no "is-a" relationship. It is purely a syntactic convenience. The outer type can shadow a promoted method by defining its own method with the same name. This is the Go equivalent of Ruby's include.Interfaces
Implicit Implementation
# Pure duck typing β no declaration needed
class Dog
def speak = "Woof!"
def move = "Run"
end
class Cat
def speak = "Meow!"
def move = "Slink"
end
def make_noise(animal) = puts animal.speak
make_noise(Dog.new)
make_noise(Cat.new) type Animal interface {
Speak() string
Move() string
}
type Dog struct{ Name string }
type Cat struct{ Name string }
func (dog Dog) Speak() string { return "Woof!" }
func (dog Dog) Move() string { return "Run" }
func (cat Cat) Speak() string { return "Meow!" }
func (cat Cat) Move() string { return "Slink" }
func makeNoise(animal Animal) {
fmt.Println(animal.Speak())
}
func printAnything(value any) {
fmt.Printf("%v (%T)\n", value, value)
}
makeNoise(Dog{Name: "Rex"})
makeNoise(Cat{Name: "Whiskers"})
// empty interface β satisfied by all types
printAnything(42)
printAnything("hello") Go interfaces are satisfied implicitly β a type implements an interface if it has all the required methods, with no
implements declaration. This is structurally equivalent to Ruby's duck typing but verified at compile time. The io.Reader, io.Writer, and error interfaces are the cornerstone of Go's standard library. any (alias for interface{}) is satisfied by all types.The error Interface
class InsufficientFundsError < StandardError
def initialize(balance, amount)
super("Need $#{amount - balance} more")
end
end
def withdraw(balance, amount)
raise InsufficientFundsError.new(balance, amount) if amount > balance
balance - amount
end
begin
withdraw(50, 100)
rescue InsufficientFundsError => error
puts error.message
end var ErrInsufficientFunds = errors.New("insufficient funds")
type WithdrawalError struct{ Shortfall float64 }
func (e *WithdrawalError) Error() string {
return fmt.Sprintf("need %.2f more", e.Shortfall)
}
func withdraw(balance, amount float64) (float64, error) {
if amount > balance {
return 0, fmt.Errorf("withdrawal failed: %w",
&WithdrawalError{Shortfall: amount - balance})
}
return balance - amount, nil
}
var withdrawalErr *WithdrawalError
// error is built-in: type error interface { Error() string }
_, err := withdraw(50, 100)
if err != nil {
fmt.Println(err)
// errors.Is β identity check (β rescue SpecificError)
if errors.Is(err, ErrInsufficientFunds) {
fmt.Println("sentinel matched")
}
// errors.As β type check (β rescue => e; e.is_a?(MyError))
if errors.As(err, &withdrawalErr) {
fmt.Printf("Short by: %.2f\n", withdrawalErr.Shortfall)
}
} The
error interface has one method: Error() string. Functions that can fail return (value, error). %w in fmt.Errorf wraps an error, preserving the chain for errors.Is (identity check) and errors.As (type check). There are no exceptions. Returning errors as values forces acknowledgment at every call site β this is intentional, not a deficiency.Type Assertions & Type Switches
def describe(value)
case value
when String then "string: #{value}"
when Integer then "integer: #{value}"
when Float then "float: #{value}"
else "unknown: #{value.class}"
end
end
puts describe("hello")
puts describe(42)
puts describe(3.14) func describe(value any) string {
switch typed := value.(type) {
case string:
return fmt.Sprintf("string: %s", typed)
case int:
return fmt.Sprintf("integer: %d", typed)
case float64:
return fmt.Sprintf("float: %.2f", typed)
default:
return fmt.Sprintf("unknown: %T", typed)
}
}
var anything any = "hello"
fmt.Println(describe("hello"))
fmt.Println(describe(42))
fmt.Println(describe(3.14))
// Single type assertion β comma-ok form (safe)
text, ok := anything.(string)
fmt.Println(text, ok) // hello true Type switches are the idiomatic pattern for branching on the underlying type of an interface value β the direct Go equivalent of Ruby's
case/when with class checks. The comma-ok form of a type assertion (value, ok := x.(Type)) is safe; the single-return form (value := x.(Type)) panics if the type does not match.Error Handling
Errors as Values
def read_config(path)
JSON.parse(File.read(path))
rescue Errno::ENOENT => error
puts "File not found: #{error.message}"
nil
rescue JSON::ParserError => error
puts "Invalid JSON: #{error.message}"
nil
end func readConfig(path string) (map[string]any, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var config map[string]any
if err := json.Unmarshal(content, &config); err != nil {
return nil, fmt.Errorf("parsing JSON: %w", err)
}
return config, nil
}
config, err := readConfig("config.json")
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Config loaded:", config)
} Go error handling is explicit and intentionally repetitive. The
if err != nil { return nil, err } pattern appears constantly. Wrap errors with %w to preserve the chain for callers using errors.Is or errors.As. There are no silent exceptions, no unexpected control-flow jumps, and no hidden rescue clauses. This verbosity is the design.defer, panic, recover
def process_resource(name)
puts "Opening #{name}"
begin
yield name
rescue RuntimeError => error
puts "Rescued: #{error.message}" # like recover
ensure
puts "Closing #{name}" # always runs β like defer
end
end
process_resource("connection") do |resource|
puts "Using #{resource}"
raise "something went wrong!"
end func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // guaranteed cleanup
return nil
}
func mustPositive(n int) int {
if n < 0 {
panic("negative input")
}
return n
}
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return a / b, nil
}
// defer β runs when surrounding function returns (like ensure/finally)
// panic β unrecoverable error (like raise for programming bugs)
// recover β catch a panic inside defer (like rescue)
result, err := safeDiv(10, 0)
fmt.Println(result, err)
fmt.Println(mustPositive(5)) defer is Go's ensure β deferred calls run when the function exits (normally or via panic) in LIFO order. Use it for cleanup: closing files, releasing locks, closing connections. panic is reserved for programming errors that should never occur β not for expected failures. recover can catch a panic inside a deferred function; this pattern is rare in application code.Pointers
Pointers (No Direct Ruby Equivalent)
# Ruby passes object references β mutation is visible to callers
def double_each(numbers)
numbers.map! { |n| n * 2 }
end
numbers = [1, 2, 3]
double_each(numbers)
puts numbers.inspect # => [2, 4, 6] func increment(counter *int) {
*counter++ // dereference with * to read/write the value
}
type Rectangle struct{ Width, Height float64 }
func (rect *Rectangle) Scale(factor float64) {
rect.Width *= factor
rect.Height *= factor
}
// Go passes by value β use * to work with the original
count := 0
increment(&count) // & takes the address of count
increment(&count)
fmt.Println(count) // 2
// new β allocates a zeroed value and returns a pointer
pointer := new(int)
*pointer = 42
fmt.Println(*pointer) // 42
// Pointer to struct
rectangle := &Rectangle{Width: 10, Height: 5}
rectangle.Scale(2)
fmt.Printf("%.0f x %.0f\n", rectangle.Width, rectangle.Height) Go is pass-by-value β every function argument is a copy. Use pointers (
*T) to share a value and allow mutation, or to avoid copying large structs. & takes the address of a variable; * dereferences a pointer to get or set its value. There is no pointer arithmetic. Ruby hides all of this β all objects are implicitly references. Go surfaces memory ownership explicitly.Goroutines & Channels
Goroutines (Lightweight Threads)
workers = (1..3).map do |id|
Thread.new { puts "Worker #{id} running" }
end
workers.each(&:join)
puts "All workers finished" func worker(id int, waitGroup *sync.WaitGroup) {
defer waitGroup.Done()
fmt.Printf("Worker %d running\n", id)
}
var waitGroup sync.WaitGroup
for id := 1; id <= 5; id++ {
waitGroup.Add(1)
go worker(id, &waitGroup)
}
waitGroup.Wait()
fmt.Println("All workers done") Goroutines are Go's lightweight concurrency primitive β far cheaper than OS threads (starting at ~2KB stack). The
go keyword starts a function call in a new goroutine and returns immediately. sync.WaitGroup is the equivalent of Thread#join: Add registers goroutines, Done signals completion (via defer), and Wait blocks until all complete.
Browser sandbox: Ruby threads run sequentially here. The output is correct; concurrent scheduling and timing effects are not demonstrated.
Channels (Communication Between Goroutines)
queue = Queue.new
producer = Thread.new do
3.times { |index| queue << index }
queue << :done
end
consumer = Thread.new do
loop do
item = queue.pop
break if item == :done
puts "Got: #{item}"
end
end
producer.join
consumer.join func produce(output chan<- int) {
for i := 0; i < 5; i++ {
output <- i
}
close(output)
}
func consume(input <-chan int) {
for value := range input {
fmt.Println("received:", value)
}
}
channel := make(chan int, 5)
go produce(channel)
consume(channel) Channels are typed, goroutine-safe queues.
<- sends to a channel; value := <-channel receives. An unbuffered channel synchronises sender and receiver β both block until the other is ready. A buffered channel blocks the sender only when full. Always close from the sender, never the receiver. range over a channel reads until it is closed.
Browser sandbox: producer thread runs to completion before the consumer starts. The output (0, 10, 20) is correct; interleaved concurrent execution is not demonstrated.
select β Multiplexing Channels
# No direct equivalent β Ruby threads block on Queue.pop
# with no built-in timeout or multi-channel select func fetchWithTimeout(channel <-chan string, timeout time.Duration) (string, bool) {
select {
case result := <-channel:
return result, true
case <-time.After(timeout):
return "", false
}
}
// select β wait on multiple channels simultaneously
channel := make(chan string, 1)
go func() {
time.Sleep(10 * time.Millisecond)
channel <- "hello"
}()
result, ok := fetchWithTimeout(channel, 1*time.Second)
fmt.Println(result, ok) select waits on multiple channel operations simultaneously β whichever is ready first executes. If multiple are ready, one is chosen at random. A default case makes the select non-blocking. time.After returns a channel that receives once after the duration β the standard timeout pattern. select is to channels what switch is to values.sync.Mutex & context.Context
mutex = Mutex.new
counter = 0
threads = 10.times.map do
Thread.new { mutex.synchronize { counter += 1 } }
end
threads.each(&:join)
puts counter # => 10 type SafeCounter struct {
mu sync.Mutex
count int
}
func (counter *SafeCounter) Increment() {
counter.mu.Lock()
defer counter.mu.Unlock()
counter.count++
}
func (counter *SafeCounter) Count() int {
counter.mu.Lock()
defer counter.mu.Unlock()
return counter.count
}
func doWork(ctx context.Context, id int) {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: cancelled\n", id)
default:
fmt.Printf("Worker %d: done\n", id)
}
}
var waitGroup sync.WaitGroup
counter := &SafeCounter{}
for i := 0; i < 5; i++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
counter.Increment()
}()
}
waitGroup.Wait()
fmt.Println("count:", counter.Count())
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
doWork(ctx, 1) sync.Mutex is Go's equivalent of Ruby's Mutex#synchronize. Use defer mu.Unlock() immediately after mu.Lock() β it is idiomatic and ensures the lock is released even on panic. context.Context propagates cancellation, deadlines, and request-scoped values across goroutines. Pass it as the first parameter to any function that does I/O. Always defer cancel().
Browser sandbox: threads run sequentially, so the mutex never contends. The final count of 10 is correct; data-race protection is not exercised here.
Packages & Modules
Module System
source 'https://rubygems.org'
gem 'rails', '~> 7.0'
gem 'sidekiq', '~> 7.0'
# bundle install
# bundle exec rails server module github.com/yourname/myapp
go 1.25
require (
github.com/some/package v1.2.3
) Go modules (
go.mod) replaced GOPATH in Go 1.11. The module path is typically a URL (github.com/user/repo) β the globally unique namespace. go.sum records cryptographic hashes of downloaded modules; always commit it. go mod tidy is idempotent β run it before every commit. There is no bundle exec equivalent; go run and go build always use the module's dependencies.Exported vs. Unexported
class BankAccount
def balance = @balance # public
def deposit(amount) = @balance += amount
private
def internal_audit = "hidden"
end package bankaccount
// Exported β uppercase first letter; visible outside the package
type Account struct {
Owner string // exported field
balance float64 // unexported β package-private
}
func NewAccount(owner string, initial float64) *Account {
return &Account{Owner: owner, balance: initial}
}
// Exported method
func (account *Account) Balance() float64 {
return account.balance
}
// Unexported β only accessible within the bankaccount package
func (account *Account) validate(amount float64) bool {
return amount > 0 && amount <= account.balance
} The capitalisation rule applies to everything at package scope: types, functions, methods, fields, variables, and constants. Uppercase = exported (public); lowercase = unexported (package-private). There are exactly two access levels β no
protected. Unexported identifiers are accessible to all code in the same package, including _test.go files that declare the same package name.Testing
go test & Table-Driven Tests
RSpec.describe Calculator do
describe '#add' do
it 'returns the sum of two numbers' do
expect(Calculator.new.add(3, 4)).to eq(7)
end
end
end // calculator_test.go β same package as the code under test
package calculator
import "testing"
func TestAdd(t *testing.T) {
result := Add(3, 4)
if result != 7 {
t.Errorf("Add(3, 4) = %d; want 7", result)
}
}
// Table-driven tests β the idiomatic Go style
func TestAddTable(t *testing.T) {
cases := []struct {
label string
input1, input2 int
expected int
}{
{"positive numbers", 3, 4, 7},
{"zeros", 0, 0, 0},
{"negative and positive", -1, 1, 0},
}
for _, testCase := range cases {
t.Run(testCase.label, func(t *testing.T) {
result := Add(testCase.input1, testCase.input2)
if result != testCase.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
testCase.input1, testCase.input2,
result, testCase.expected)
}
})
}
} Tests live in
*_test.go files alongside the code they test. Test functions must begin with Test and accept *testing.T. Table-driven tests (a slice of struct test cases) are the standard Go idiom β equivalent to RSpec's parameterised examples. Use t.Errorf for non-fatal failures and t.Fatalf to stop immediately. There is no built-in assertion library.β Gotchas for Rubyists
Unused Variables & Imports Are Compile Errors
result = 42 # assigned but never read β Ruby raises no error
# RuboCop warns: W: Lint/UselessAssignment
puts "done" // result := expensiveOperation()
// error: result declared and not used
// Fix: explicitly discard with _
_ = expensiveOperation()
fmt.Println("done")
// Unused imports are also compile errors.
// Fix: import _ "pkg" for side-effects-only imports The Go compiler refuses to compile code with unused local variables or unused imports. This catches typos and dead code at compile time. The blank identifier
_ explicitly discards a value and tells the compiler "I know I'm not using this". Blank imports (import _ "pkg") run the package's init() for side effects (e.g., registering a database driver).nil Is Not Falsy
In Go,
nil is not falsy β there is no automatic truthiness conversion. Boolean expressions must be of type bool.value = nil
if value
puts "truthy"
else
puts "falsy" # => "falsy" β nil is falsy in Ruby
end var pointer *int = nil
type Animal interface{ Speak() string }
type Dog struct{}
func (dog *Dog) Speak() string { return "Woof" }
var dog *Dog = nil
var animal Animal = dog // animal holds (*Dog, nil) β type is set!
// if pointer { ... } // compile error! *int is not a bool
if pointer == nil {
fmt.Println("pointer is nil")
}
// Nil interface subtlety β a typed nil is NOT nil as an interface
fmt.Println(dog == nil) // true
fmt.Println(animal == nil) // FALSE! The nil interface subtlety is a classic Go bug: an interface value is
nil only if both its type and value fields are nil. Assigning a typed nil pointer to an interface sets the type field, making the interface non-nil even though the underlying pointer is nil.Slices Share Backing Arrays
Unlike Ruby's array slicing (which returns an independent copy), Go slice expressions share the underlying backing array. Modifying elements through one slice modifies the other.
original = [1, 2, 3, 4, 5]
sliced = original[1..3] # independent copy
sliced[0] = 99
puts original.inspect # => [1, 2, 3, 4, 5] β unchanged
puts sliced.inspect # => [99, 3, 4] original := []int{1, 2, 3, 4, 5}
sliced := original[1:4] // shares the backing array!
sliced[0] = 99
fmt.Println(original) // [1 99 3 4 5] β MODIFIED!
fmt.Println(sliced) // [99 3 4]
// To get an independent copy β use copy() or append trick
independent := make([]int, len(original[1:4]))
copy(independent, original[1:4])
independent[0] = 0
fmt.Println(original) // [1 99 3 4 5] β still modified
fmt.Println(independent) // [0 3 4] Use
copy() or append([]int{}, original[1:4]...) to get a truly independent copy. This sharing is intentional for performance but is a common source of subtle bugs when slices are passed to goroutines.No Implicit String Conversion (string(65) Trap)
Watch out: Go never converts types implicitly, and
string(65) does not produce "65" β it produces "A" (Unicode code point 65). Rubyists reaching for integer-to-string conversion should use strconv.Itoa instead. strconv.Atoi does the reverse and returns (int, error) because the conversion can fail.count = 42
puts "Count: " + count.to_s # explicit .to_s
puts "Count: #{count}" # implicit via interpolation
puts 65.to_s # => "65" count := 42
// "Count: " + count // compile error! cannot add string and int
// Fix 1: fmt.Sprintf (idiomatic for formatting)
message := fmt.Sprintf("Count: %d", count)
fmt.Println(message)
// Fix 2: strconv (for pure conversion)
message2 := "Count: " + strconv.Itoa(count)
fmt.Println(message2)
// TRAP: string(integer) converts a rune, NOT a number string
fmt.Println(string(65)) // "A" β rune conversion!
fmt.Println(strconv.Itoa(65)) // "65" β this is what you want
// strconv.Atoi β string to int, returns (int, error)
number, err := strconv.Atoi("42")
fmt.Println(number, err) // 42 Goroutine Leaks
# Ruby threads are GC'd when no longer referenced
thread = Thread.new { sleep 1; puts "done" }
thread.join // Goroutines are NOT GC'd while running or blocked.
// A goroutine blocked on a channel with no sender leaks forever:
//
// leaked := make(chan int)
// go func() {
// value := <-leaked // blocks until process exits β memory never freed
// fmt.Println(value)
// }()
//
// Fix: always give goroutines a way to stop β context.Context or a done channel.
//
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// defer cancel()
// go func() {
// select {
// case value := <-results:
// fmt.Println(value)
// case <-ctx.Done():
// return // goroutine exits cleanly
// }
// }()
fmt.Println("goroutine leaks are not runtime errors β they are invisible until OOM") Goroutines are not garbage-collected while running or blocked. A goroutine blocked on a channel receive with no sender is a goroutine leak β it holds memory until the process exits. Always give long-running goroutines a way to stop: pass a
context.Context and check ctx.Done(), or signal via a dedicated done channel. The goleak package detects leaks in tests.
Browser sandbox:
sleep 1 is a no-op (returns immediately). The thread runs synchronously; the GC behaviour being illustrated is real Ruby, not observable here.Maps Are Not Ordered
scores = { "Alice" => 95, "Bob" => 87, "Carol" => 92 }
scores.each { |name, score| puts "#{name}: #{score}" }
# Ruby Hash preserves insertion order (since Ruby 1.9)
# Output is always: Alice, Bob, Carol scores := map[string]int{
"Alice": 95,
"Bob": 87,
"Carol": 92,
}
// Iteration order is RANDOM β different on every run
for name, score := range scores {
fmt.Printf("%s: %d\n", name, score)
}
// For deterministic order, sort the keys first
names := make([]string, 0, len(scores))
for name := range scores {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Printf("%s: %d\n", name, scores[name])
} Go map iteration order is intentionally randomised on each run β the runtime shuffles it to prevent programs from accidentally depending on insertion order. Unlike Ruby's
Hash (which has preserved insertion order since Ruby 1.9), Go maps make no ordering guarantee. To iterate in consistent order, collect the keys into a slice, sort it, then iterate.defer Inside Loops Does Not Run Per-Iteration
Watch out:
defer is scoped to the function, not the loop iteration β it runs when the surrounding function returns, not at the end of each loop body.items = ["alpha", "beta", "gamma"]
items.each do |item|
resource = "resource-#{item}"
begin
puts "Processing #{item}"
ensure
puts "Closed #{resource}" # runs at END of each iteration β not deferred
end
end items := []string{"alpha", "beta", "gamma"}
// WRONG β all defers run when main() exits, not each iteration.
// This queues up all three Println calls β they fire LIFO at function exit.
for _, item := range items {
item := item // capture loop variable
defer fmt.Println("deferred (runs at exit):", item)
}
fmt.Println("--- loop finished ---")
// RIGHT β wrap the body in a closure so each call gets its own defer scope.
process := func(item string) {
defer fmt.Println("closed:", item) // runs when process() returns
fmt.Println("processing:", item)
}
for _, item := range items {
process(item)
} Rubyists expect
ensure-like per-iteration cleanup, but defer inside a loop queues all cleanup until the entire function exits, potentially exhausting file descriptors. Fix: wrap the loop body in a helper function so each call gets its own defer scope.