Ruby.CodeCompared.To/Go

An interactive executable cheatsheet for Rubyists learning Go

Ruby 4.0 Go 1.26.2
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.