baweaver

A Rubyist in Go Land: Your First Pokémon API Client


When I was growing up there was one game that I played to death: Pokémon Blue. Of course, that was after I figured out the carpet was the exit for the house, which took longer than I’d care to admit. It was my start into technology, along with Legend of Zelda, that made me want to try my own hand at making games and RPGs. Not long after I stumbled upon RPG Maker, and when the XP version came out it had this lovely scripting engine called RGSS where you could modify anything, and to kid me that was a wondrous playground. Oh, and RGSS? That was Ruby Game Script System, which means I’ve been doing Ruby for north of 20 years now if we count that at the time of writing this article.

Why am I mentioning that? Last fall I was in the audience for Tess Griffin’s Learning Empathy from Pokémon Blue at Rocky Mountain Ruby. It covered the infamous MissingNo glitch, how it functioned, how well-intentioned developers may have ended up writing it. It reminded me of those times. Fast forward and she gave the same talk again at RubyConf this year, and it gave me a few ideas of how I’d approach another adventure I find myself on.

A lot of folks know me as the “Ruby guy” but I do know other languages like Scala, Javascript, Python, and a smidge of Go from previous roles. Granted not as deeply, but part of how you get there is building things and experimenting, and as of recently I need to get back up to speed on Go so here we are.

Given that, I’m looking back at Pokémon again, and more directly PokéAPI, the open Pokémon data API. We’re going to go through API clients, how I might write things in Ruby, and what that translates to in Go while trying to behave myself and not rewriting Ruby in Go.

To be clear this series isn’t going to teach you Go syntax, and it’s not going to rank the two against each other. I’m evaluating which of my Ruby instincts translate and which don’t, and experimenting with tooling I’m not immediately familiar with. That means to be fair, as mentioned above, I will be attempting to write Go like a Go programmer (correct me if I miss here) instead of like a Rubyist.

Shall we get started then?

A local Pokédex first

While PokéAPI is a great resource and API, it would be bad manners to point readers directly at it, so before writing a client we’re going to stand up a local copy to experiment against instead of hammering the live server.

The project publishes its entire dataset as static JSON in PokeAPI/api-data, which makes “run PokéAPI locally” less adventurous than it sounds. We pull the handful of Pokémon this post needs into a fixtures directory:

require "fileutils"
require "net/http"

MIRROR = "https://raw.githubusercontent.com/PokeAPI/api-data/master/data"
FIXTURE_DIR = File.expand_path("fixtures/data/api/v2/pokemon", __dir__)

# api-data stores records by id, so we keep a small name lookup here.
# Add to this map as the series grows.
POKEMON = {
  "bulbasaur"  => 1,
  "charmander" => 4,
  "squirtle"   => 7,
  "pikachu"    => 25
}.freeze

FileUtils.mkdir_p(FIXTURE_DIR)

POKEMON.each do |poke_name, national_id|
  response = Net::HTTP.get_response(URI("#{MIRROR}/api/v2/pokemon/#{national_id}/index.json"))

  unless response.is_a?(Net::HTTPSuccess)
    abort("Mirror returned #{response.code} for #{poke_name}, giving up")
  end

  File.write(File.join(FIXTURE_DIR, poke_name), response.body)
  File.write(File.join(FIXTURE_DIR, national_id.to_s), response.body)

  puts format("fetched %-10s (%6d bytes)", poke_name, response.body.bytesize)
end

Giving us back files we can serve directly from our own lightweight server:

$ ruby fetch_fixtures.rb
fetched bulbasaur  (537794 bytes)
fetched charmander (599737 bytes)
fetched squirtle   (605737 bytes)
fetched pikachu    (572204 bytes)

And we can write that server with a quick Go script:


// Serves the local PokeAPI fixtures so we never hit the live API.
//
//    go run ./cmd/serve-fixtures
//    curl http://localhost:9595/api/v2/pokemon/bulbasaur
package main

import (
    "log"
    "net/http"
    "os"
)

func main() {
    // Default to the fixtures directory the fetch script creates.
    // Pass a different path as an argument if you cloned api-data.
    fixtureDir := "fixtures/data"
    if len(os.Args) > 1 {
        fixtureDir = os.Args[1]
    }

    // `http.FileServer` serves static files from a directory. It maps
    // the request path directly to the filesystem, so a request for
    // /api/v2/pokemon/bulbasaur looks for fixtures/data/api/v2/pokemon/bulbasaur.
    // No routing needed because we wrote the fixture files without extensions.
    //
    // `http.ListenAndServe` blocks forever (or until it errors).
    // `log.Fatal` prints the error and exits if the server dies.
    address := ":9595"
    log.Printf("Serving %s on http://localhost%s/api/v2", fixtureDir, address)
    log.Fatal(http.ListenAndServe(address, http.FileServer(http.Dir(fixtureDir))))
}

Which we can run:

$ go run ./cmd/serve-fixtures
2026/07/18 05:02:10 Serving fixtures/data on http://localhost:9595/api/v2

Wait, why not just clone the repo? The full api-data dataset is around 3GB for four Pokémon we actually need. The fetch script grabs only what this post uses, the Go server doubles as our first piece of Go to read, and the fixtures stay small enough to commit alongside the article code.

The Ruby baseline

The task for this first post is deliberately small, because we want to focus on which instincts transfer and which do not. For the sake of this article we want to fetch one pokémon and print its number, name, types, abilities, and base stats.

We would start by fetching the data we need and converting that to JSON:

# Fetches one Pokemon and prints its profile. Point POKEAPI_URL at a
# different server to use the live API or another mirror.
#
#   ruby pokemon.rb bulbasaur
#   POKEAPI_URL=https://pokeapi.co/api/v2 ruby pokemon.rb bulbasaur

require "json"
require "net/http"

BASE_URL = ENV.fetch("POKEAPI_URL", "http://localhost:9595/api/v2")

# Yes, I like my Perl-isms
query = ARGV.first or abort("usage: pokemon.rb NAME")

response = Net::HTTP.get_response(URI("#{BASE_URL}/pokemon/#{query.downcase}"))
abort("No Pokémon named #{query.inspect}") unless response.is_a?(Net::HTTPSuccess)

pokemon = JSON.parse(response.body, symbolize_names: true)

Then we can extract the data we need using pattern matching, and more specifically right-hand assignment, by leveraging the symbolize_names option above to make all keys Symbols:

pokemon => { id:, name:, types:, abilities:, stats: }

And finally we can get to some basic display logic:

puts "##{id.to_s.rjust(4, '0')} #{name.capitalize}"
puts "Types:     #{types.sort_by { _1[:slot] }.map { _1.dig(:type, :name) }.join(' / ')}"
puts "Abilities: #{abilities.map { _1.dig(:ability, :name) }.join(', ')}"
puts

stats
  .map { [_1.dig(:stat, :name), _1[:base_stat]] }
  .sort_by(&:last)
  .reverse_each do |stat_name, base_stat|
    puts format("%-16s %3d %s", stat_name, base_stat, "█" * (base_stat / 5))
  end

Which results in:

$ POKEAPI_URL=http://localhost:9595/api/v2 ruby pokemon.rb bulbasaur
#0001 Bulbasaur
Types:     grass / poison
Abilities: overgrow, chlorophyll

special-defense   65 █████████████
special-attack    65 █████████████
defense           49 █████████
attack            49 █████████
speed             45 █████████
hp                45 █████████

The whole script is 33 lines, and it reads like the task description. You’ll already notice a few habits I have in writing this:

  • fetch - For Hashes I tend to prefer fetch with a reasonable default.
  • or - Some Rubyists avoid the english operators, but I tend to like them for things like this, even if it’s Perl-y.
  • abort - Early guards to bail out, and postfix conditionals to gate them. Admittedly I’m almost inclined to write response.is_a?(Net::HTTPSuccess) or abort("No Pokémon named #{query.inspect}") but then I’m being deliberately dense lexically.
  • symbolize_names - Hash pattern matching in Ruby only works on Symbol keys, so I tend to have this option on more often than not.
  • Pattern matching - I use it, I like it. Scala, Rust, Elixir, Javascript, and other languages spoiled me so yes I’m using it with some regularity in Ruby
  • Enumerable - I use a ton of Enumerable methods for sorting, transforming, and other operations
  • Shorthand - _1 and it are common in my code when it makes sense, though less so if I want to aim for readability as a primary focus.
  • dig - Traversing nested hashes is a lot quicker with dig around, and I use it liberally.

The most unusual habit in that list is the pattern matching, and more specifically rightward assignment:

pokemon => { id:, name:, types:, abilities:, stats: }

It’s doing more than just destructuring values, it’s also verifying the shape of the JSON. If a key is missing it will raise a NoMatchingPatternKeyError, giving us a pretty clear warning sign that something in the API changed or we might have assumed something was there that frankly wasn’t.

Granted I’m being a bit clever here in showing off various Ruby tools and tricks, but it’s close to how I might write Ruby at home. At work I do try and be just a tinge more readable, unless it’s a one-off script. Now let’s see what the same task looks like in Go.

Starting over in Go

To reiterate: I do not know Go deeply, so if anything you read here is deeply offensive to your Go sensibilities feel free to comment, I have a lot to learn still in this language and am always welcoming new ideas.

That said, the start for this program is something I took for granted in Ruby: Types on inbound JSON:


// In Go you define the shape of your data with structs before you use
// it. There is no equivalent to a loose Hash; every field has a name
// and a type declared up front. The backtick annotations (called
// "struct tags") tell the JSON decoder which key in the JSON maps to
// which field. `json:"name"` means "fill this field from the JSON key
// called name."
//
// In Ruby you'd just parse into a Hash and dig through it. Go makes
// you name everything first, but in return the compiler catches typos
// and wrong types before the code ever runs.

// NamedResource is PokeAPI's NamedAPIResource: a name plus a URL
// pointing at the full record. It appears all over the schema in
// types, abilities, and stats, so we extract it once.
type NamedResource struct {
    Name string `json:"name"`
    URL  string `json:"url"`
}

// Each of these structs maps to one object in the JSON arrays.
// `[]TypeSlot` in the Pokemon struct below means "a slice (dynamic
// array) of TypeSlot values."
type TypeSlot struct {
    Slot int           `json:"slot"`
    Type NamedResource `json:"type"`
}

type AbilitySlot struct {
    Ability  NamedResource `json:"ability"`
    IsHidden bool          `json:"is_hidden"`
}

type StatLine struct {
    BaseStat int           `json:"base_stat"`
    Stat     NamedResource `json:"stat"`
}

// Pokemon is the top-level response from GET /api/v2/pokemon/{name}.
// We only declare the fields we care about; the decoder silently
// ignores any JSON keys that don't have a matching struct field.
type Pokemon struct {
    ID        int           `json:"id"`
    Name      string        `json:"name"`
    Types     []TypeSlot    `json:"types"`
    Abilities []AbilitySlot `json:"abilities"`
    Stats     []StatLine    `json:"stats"`
}

The Name and URL pair repeats across types, abilities, and stats, and extracting it reproduces a concept PokéAPI’s docs already define as NamedAPIResource. In Go it feels apt to start with the shape of the data we’re working with, much as I might with F# from my time reading Domain Modeling Made Functional.

The rest of the draft is plain: http.Get in main, decode into Pokemon, print:


// Go has no built-in `capitalize`, so you write one yourself.
// This only handles ASCII which is fine for Pokémon names.
// In Ruby: `name.capitalize`.
func capitalize(word string) string {
    if word == "" {
        return word
    }
    return strings.ToUpper(word[:1]) + word[1:]
}

func main() {
    // `os.Args` includes the program name at index 0 (unlike Ruby's
    // ARGV which starts at the first real argument).
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: pokemon-quick NAME")
        os.Exit(1)
    }

    // `cmp.Or` returns the first non-zero value. For strings the zero
    // value is "", so this works like `ENV.fetch("POKEAPI_URL", default)`.
    baseURL := cmp.Or(os.Getenv("POKEAPI_URL"), "https://pokeapi.co/api/v2")

    // `http.Get` uses Go's default HTTP client, which has NO timeout.
    // If the server hangs, this hangs forever. We fix this later.
    // The `(response, err)` return pair is Go's convention for fallible
    // operations. You must check `err` before touching `response`.
    response, err := http.Get(fmt.Sprintf("%s/pokemon/%s", baseURL, strings.ToLower(os.Args[1])))
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    // `defer` is Go's `ensure`. It runs when the function exits,
    // regardless of how. We need it because the response body is an
    // open connection that leaks if we forget to close it.
    //
    // The `func() { _ = ... }()` wrapper is because Go's `errcheck`
    // linter complains if you ignore the error from `Close()`. The
    // `_ =` explicitly discards it, which tells the linter "yes I know
    // this returns an error, I'm choosing to ignore it here because
    // there's nothing useful to do with a close error on a read body."
    defer func() { _ = response.Body.Close() }()

    if response.StatusCode != http.StatusOK {
        fmt.Fprintf(os.Stderr, "no Pokémon named %q (%s)\n", os.Args[1], response.Status)
        os.Exit(1)
    }

    // `json.NewDecoder` reads JSON from the response body stream and
    // `Decode(&pokemon)` fills our struct fields using the `json:""`
    // tags we defined above. The `&` passes a pointer so the decoder
    // writes into our variable rather than a copy.
    // In Ruby this whole step is `JSON.parse(body, symbolize_names: true)`.
    var pokemon Pokemon
    if err := json.NewDecoder(response.Body).Decode(&pokemon); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

And the display:


// `slices.SortFunc` sorts a slice in-place using a comparison
// function you provide. In Ruby you'd write `sort_by { _1.slot }`
// or use `<=>` in a sort block. Go does not have `<=>` as an
// operator, so the `cmp` package fills that role.
//
// `cmp.Compare(a, b)` takes two ordered values and returns:
//   -1 if a < b
//    0 if a == b
//   +1 if a > b
//
// This is the same contract as Ruby's `<=>`. The function you pass
// to `SortFunc` receives two elements and returns negative (left
// first), zero (equal), or positive (right first).
//
// We sort by `Slot` so the primary type (slot 1) prints before the
// secondary type (slot 2).
slices.SortFunc(pokemon.Types, func(left, right TypeSlot) int {
    return cmp.Compare(left.Slot, right.Slot)
})

// `make([]string, n)` allocates a string slice of length n, all
// empty strings. We fill it by walking the sorted types and pulling
// each name out.
//
// In Ruby this would be:
//   pokemon.types.map { _1.dig(:type, :name) }
//
// Go has no `map` equivalent in the standard library, you write the
// loop. The tradeoff is that you always know exactly what's
// happening and what type you're working with at each step.
typeNames := make([]string, len(pokemon.Types))
for position, slot := range pokemon.Types {
    typeNames[position] = slot.Type.Name
}

// Same pattern for abilities: allocate, loop, extract.
abilityNames := make([]string, len(pokemon.Abilities))
for position, slot := range pokemon.Abilities {
    abilityNames[position] = slot.Ability.Name
}

// `fmt.Printf` works like C's `printf` or Ruby's `format`/`sprintf`.
// `%04d` pads the ID to 4 digits with leading zeros.
// `strings.Join` is the equivalent of `Array#join` in Ruby.
fmt.Printf("#%04d %s\n", pokemon.ID, capitalize(pokemon.Name))
fmt.Printf("Types:     %s\n", strings.Join(typeNames, " / "))
fmt.Printf("Abilities: %s\n\n", strings.Join(abilityNames, ", "))

// Sort stats descending by base value. Note `right.BaseStat` comes
// first in `cmp.Compare`, which reverses the order. In Ruby you'd
// write `sort_by(&:last).reverse_each` or `sort_by { -_1.last }`.
// Go has no reverse sort shorthand, you swap the arguments.
slices.SortFunc(pokemon.Stats, func(left, right StatLine) int {
    return cmp.Compare(right.BaseStat, left.BaseStat)
})

// `%-16s` left-aligns the stat name in a 16-character field.
// `%3d` right-aligns the number in 3 characters.
// `strings.Repeat("█", n)` is the equivalent of `"█" * n` in Ruby.
for _, line := range pokemon.Stats {
    fmt.Printf("%-16s %3d %s\n", line.Stat.Name, line.BaseStat, strings.Repeat("█", line.BaseStat/5))
}

Which gives us this:

$ POKEAPI_URL=http://localhost:9595/api/v2 go run ./cmd/pokemon-quick bulbasaur
#0001 Bulbasaur
Types:     grass / poison
Abilities: overgrow, chlorophyll

special-attack    65 █████████████
special-defense   65 █████████████
attack            49 █████████
defense           49 █████████
hp                45 █████████
speed             45 █████████

The quick draft works, but it has no timeout. If the server hangs, so does the script, which was something I definitely found surprising coming from Ruby.

Where the quick draft breaks

Let’s deliberately make a server that accepts a connection and never responds to prove this:

$ ruby -e '
  require "socket"
  server = TCPServer.new("127.0.0.1", 9999)
  conn = server.accept
  sleep 120
' &

$ POKEAPI_URL=http://127.0.0.1:9999/api/v2 timeout 15 go run ./cmd/pokemon-quick bulbasaur
$ echo $?
124

Exit code 124 means timeout killed the process at the fifteen second cap; left alone, it waits forever. Ruby is more defensive here. Net::HTTP ships with 60 second open and 60 second read timeouts by default:

def net_http_defaults
  http = Net::HTTP.new("example.com")

  {
    open_timeout: http.open_timeout,
    read_timeout: http.read_timeout
  }
end

Running it:

$ ruby net_http_defaults.rb
open_timeout: 60
read_timeout: 60

So the Ruby baseline gives up after a minute, and the Go draft hangs until you kill it. The fix is to create a client with an explicit deadline:


// `Client` is a struct that holds the configuration for talking to
// PokeAPI. In Ruby you might make a class with `initialize` storing
// `@base_url` and `@http`. Go uses structs with fields instead of
// instance variables. Lowercase field names (`baseURL`, `httpClient`)
// are private to this package, like Ruby's `private attr_reader`.
type Client struct {
    baseURL    string
    httpClient *http.Client
}

// `New` is the conventional Go constructor name (there are no special
// constructor methods like `initialize`). It returns a pointer to a
// Client (`*Client`). The `&Client{...}` syntax allocates a Client
// and returns its address.
//
// The key difference from the quick draft: we build our own
// `http.Client` with an explicit 10-second timeout. The default
// client that `http.Get` uses has NO timeout at all, which means a
// hung server will block forever. Ruby's `Net::HTTP` defaults to 60
// seconds for both open and read; Go defaults to infinity.
func New(baseURL string) *Client {
    if baseURL == "" {
        baseURL = DefaultBaseURL
    }

    return &Client{
        baseURL: strings.TrimRight(baseURL, "/"),
        httpClient: &http.Client{
            Timeout: 10 * time.Second,
        },
    }
}

The client moves into internal/pokeapi. Go’s toolchain rejects imports of internal/ packages from outside this module, which gives the repo a private core without access-modifiers like you might find in Ruby:


// `Pokemon` is a method on `*Client` (the `(client *Client)` part is
// called a "receiver", Go's version of `self`). It takes a
// `context.Context` and a name, and returns either a `Pokemon` value
// or an error. Go functions can return multiple values; the
// convention for fallible operations is `(result, error)`.
//
// `context.Context` carries deadlines and cancellation signals. When
// the client's 10-second timeout fires, the context is cancelled and
// the request aborts. In Ruby you'd set `Net::HTTP#read_timeout`;
// Go threads the deadline through every layer via context.
func (client *Client) Pokemon(ctx context.Context, name string) (Pokemon, error) {
    requestURL := fmt.Sprintf("%s/pokemon/%s", client.baseURL, strings.ToLower(name))

    // `http.NewRequestWithContext` builds a request and attaches the
    // context to it. This is how the timeout reaches the network call.
    // If the URL is malformed this returns an error immediately.
    request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
    if err != nil {
        return Pokemon{}, fmt.Errorf("building request for %s: %w", name, err)
    }

    // `client.httpClient.Do(request)` sends the request using our
    // configured client (with the timeout). Unlike `http.Get` which
    // uses the global default client, this respects our deadline.
    response, err := client.httpClient.Do(request)
    if err != nil {
        // `%w` wraps the original error so callers can inspect it with
        // `errors.Is` or `errors.As` later. This is Go's version of
        // exception chaining (like Ruby's `cause`).
        return Pokemon{}, fmt.Errorf("fetching %s: %w", name, err)
    }
    // Explicitly discard the Close error with `_ =` to satisfy the
    // errcheck linter. A close error on a response we already read
    // from has no recovery path, but the linter wants proof you
    // thought about it.
    defer func() { _ = response.Body.Close() }()

    if response.StatusCode != http.StatusOK {
        return Pokemon{}, fmt.Errorf("no Pokémon named %q (%s)", name, response.Status)
    }

    var pokemon Pokemon
    if err := json.NewDecoder(response.Body).Decode(&pokemon); err != nil {
        return Pokemon{}, fmt.Errorf("decoding %s: %w", name, err)
    }

    return pokemon, nil
}

One thing that I actually like about Go from the start is that every operation that can fail canonically returns an err (error) value that has to be handled upon calling. It mirrors a strong preference I have in Ruby to never use exceptions for flow control and instead return either reasonable defaults, or some type of significant error value that can be handled. What I don’t like about it is it feels like a partial implementation of a Result type from something like Scala or Haskell, and composing or piping them together can be… involved. The Go community has known this for years, and the Go team recently decided not to fix it at the syntax level. I think that’s an oversight that should have been corrected, but it’s what we have to work with.

You’ll notice fmt.Errorf("fetching %s: %w", name, err) throughout. The %w verb wraps the original error inside a new one with added context, similar to Ruby’s Exception#cause chain. Each caller can add its own layer of “what was I doing when this failed” without losing the original error underneath. When you want to check what went wrong later, errors.Is can unwrap the chain and match against a specific error type.

The other new concept is context.Context, that ctx parameter on the method. A context carries deadlines and cancellation signals through the call stack. When our client’s 10-second timeout fires, the context is cancelled and the in-flight HTTP request aborts. In Ruby you’d set Net::HTTP#read_timeout on the client object itself; Go threads the deadline through every function call via this parameter. It’s verbose, but it means any function in the chain can tighten the deadline further or cancel the work early without reaching back into the client.

With the client extracted, main gets simple. All it does is call run, and run returns errors instead of calling os.Exit directly:


// `main` does one thing: call `run` and translate its error into an
// exit code. This pattern keeps `main` trivial and makes the real
// logic testable (you can call `run` from a test without triggering
// `os.Exit`). In Ruby you'd put everything in the script body; Go
// convention separates "exit policy" from "program logic."
func main() {
    if err := run(os.Args[1:]); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

// `run` returns an error instead of calling `os.Exit` directly.
// Every failure path returns an error with context about what went
// wrong, and `main` decides what to do with it (here: print and exit 1).
// In Ruby you'd `abort` or `raise`; Go makes the error a return value
// you pass back up the call stack.
func run(args []string) error {
    if len(args) < 1 {
        return fmt.Errorf("usage: pokemon NAME")
    }

    // `pokeapi.New` is our client constructor from internal/pokeapi.
    // The `internal/` directory means only code inside this module can
    // import it. Other Go modules cannot reach in, giving us a private
    // API boundary without access modifiers on every method.
    client := pokeapi.New(os.Getenv("POKEAPI_URL"))

    // `context.Background()` is the root context with no deadline of
    // its own. The client's 10-second timeout still applies because
    // `New` configured it on the `http.Client`. In later posts we'll
    // pass contexts with tighter deadlines or cancellation signals.
    pokemon, err := client.Pokemon(context.Background(), args[0])
    if err != nil {
        return err
    }

    printProfile(pokemon)
    return nil
}

Point the finished version at the same unresponsive server and the hang becomes a bounded failure at the configured 10 seconds:

$ POKEAPI_URL=http://127.0.0.1:9999/api/v2 go run ./cmd/pokemon bulbasaur
fetching bulbasaur: Get "http://127.0.0.1:9999/api/v2/pokemon/bulbasaur": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Against the local mirror it prints the same profile as the quick draft.

Unsurprisingly the Ruby script is shorter than the Go one, but Go is also putting a lot of structure around data and failure shapes, which a lot of Ruby programs start progressing towards as they mature. Whether that’s a good trade depends on how long the program lives and how many hands touch it, as well as if there are any speed constraints.

On Performance: YAGNI (you aren’t going to need it) applies here. If I can write a Ruby script in 1-2 minutes and it runs in 1-2 seconds versus writing the same thing in Go in probably 10+ minutes for a runtime of fractions of a second it’s not a good trade. Most things do not need performance as a first-class consideration, they need to be done. Focus on value propositions first, and if performance is part of that proposition then have at it. Granted I’ve written Ruby for 20+ years now, and only minor contributions in Go, maybe I get faster in Go over time too which shifts that equation a bit.

Shape first

Let’s go back to how both languages are handling the shapes of data:

pokemon => { id:, name:, types:, abilities:, stats: }

And in Go:

var pokemon Pokemon

If a key is missing from the JSON, => raises NoMatchingPatternKeyError at runtime. If you try to access pokemon.Nonexistent in Go, the compiler rejects it at build time. My tendency to verify shapes carries over from Ruby, the difference is that in Ruby it’s a preference I opt into and in Go it’s a requirement the compiler enforces. On one hand having free and easy JSON deserialization in Ruby is nice, but on the other getting a very clear compiler level warning that something changed becomes very useful as applications grow.

My stance on Go so far

I’m still not entirely sure what I think of Go. There are things I like about it like simplicity, minimalism, and making errors explicit and top level flow control rather than throw-catch patterns. The contradiction is that’s also the reason I find Go a bit frustrating as all that minimalism ends up inflicting application complexity on the programmer, and no two programmers ever agree on how to solve those types of problems. Personally I have a preference towards having clear, obvious ways to do things that are base-level ergonomics like Enumerable in Ruby, Result types in Scala, or Promises in JS. Go still hasn’t convinced me on that front, but it doesn’t need to for me to get to a productive level in it either.

As programmers we’re allowed to not like things and disagree with them, if anything it’s in our very nature to be somewhat argumentative, but what’s more important is collectively being on the same page as teams and organizations about what and why we do things. As long as the bikeshed can store bikes I personally don’t care if it’s painted red, blue, or some other random color. I focus on the value proposition, not the edge dressing.

What’s next

I’m keeping these posts small so I’ve elided a decent amount of concepts. I haven’t given contexts, error handling, servers, or goroutines much time yet. I’ll have to look into which one to explore next.