- Book: Observability for LLM Applications — paperback and hardcover on Amazon · Ebook from Apr 22
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Rails gave you a world where the framework composes everything. Controllers, routes, validations, migrations, background jobs, asset pipeline, mailer, cache store. You typed rails new, and the architecture arrived in a box. You learned where the hooks were, and from that point the work was writing business code, not writing plumbing.
Go hands you a main() function and a standard library. The shock isn't that Go is slower to start. The shock is that the framework never arrives. There is no rails new for a Go service. There is no convention that picks your ORM, your router, your job queue, your config loader. You pick. Every time. Every project.
This is a field guide for the Rails developer looking at a Go codebase on their first day at a company that migrated. What you gain, what you lose, what takes three weeks to stop feeling wrong.
flowchart LR
subgraph RAILS["Rails convention"]
GEN[rails g scaffold Post] --> AUTO[Auto-generates<br/>model + controller + views + routes + migrations]
end
subgraph GO["Go explicit"]
HAND[You write] --> M[post.go]
HAND --> H[handler.go]
HAND --> R[routes.go]
HAND --> MIG[migrations/]
end
Convention over configuration vs explicit is better than clever
Rails ships with opinions. A User model looks for a users table. A UsersController#show renders users/show.html.erb. A route helper appears because you wrote resources :users. You do nothing. The framework does the wiring.
Go does none of this. The import path of a package is exactly where it lives on disk. A route exists because you wrote mux.HandleFunc("GET /users/{id}", handler). There is no "registry" that knows about your handlers. There is no auto-loader. If a function is called, a human typed its name somewhere upstream.
The first week feels like everything takes longer. It does. A new endpoint in Rails is a line in routes.rb, a method on a controller, a view template. A new endpoint in Go is a handler function, its registration line, its request struct, its response struct, its test, and probably a SQL query you wrote yourself. Five things instead of three.
The payoff is that you can read any Go codebase by following function calls. There is no ApplicationRecord ancestor chain to trace. No before_action :authenticate! added by a mixin three levels up. If authentication happens in a handler, you will see it, because a human wrote it there.
This is the trade. Rails lets you move fast until the magic bites. Go is slower until you stop looking for magic that isn't coming.
ActiveRecord to sqlc: you write the SQL now
ActiveRecord is the gravitational center of Rails. User.where(active: true).includes(:posts).order(created_at: :desc).limit(10) reads like English and compiles to SQL that is almost always acceptable. When it isn't, you drop to find_by_sql and move on.
Go does not have an ORM with that reach. The community settled, roughly, on three camps: hand-rolled database/sql, a thin query builder like squirrel, or code generation from SQL with sqlc. The one you see most in production right now is sqlc, because it inverts the Rails assumption in a way Go programmers like.
You write the SQL. sqlc reads your schema, reads your queries, and generates typed Go functions from them.
Rails version:
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
scope :active_since, ->(t) { where("last_seen_at > ?", t) }
end
# somewhere in a controller
recent = User.active_since(1.week.ago).includes(:posts).limit(10)
Go version, with sqlc:
-- db/queries.sql
-- name: ActiveSince :many
SELECT id, email, last_seen_at
FROM users
WHERE last_seen_at > $1
ORDER BY last_seen_at DESC
LIMIT $2;
// handler.go
users, err := q.ActiveSince(ctx, db.ActiveSinceParams{
LastSeenAt: time.Now().Add(-7 * 24 * time.Hour),
Limit: 10,
})
if err != nil {
return fmt.Errorf("load active users: %w", err)
}
You lose associations. If you need the posts for each user, you write a second query, or a join, and you deal with the shape yourself. There is no includes(:posts) that quietly fans out to an IN query in the background.
What you gain is a compile-time check on every column, every parameter, every return shape. When a migration drops last_seen_at, sqlc generate fails. The handler does not compile. You find the drift on your laptop, not in a 500 at 3am.
Sidekiq to goroutines plus a queue library
Sidekiq is one of the genuinely great pieces of Ruby infrastructure. A worker class, a perform_async, a Redis instance, and you have background jobs that retry on failure and scale to thousands of workers. Most Rails teams don't think about job processing the way they think about web requests, because Sidekiq did the thinking.
Go gives you concurrency primitives, not a job system. A goroutine is cheap. Spawning ten thousand of them costs you a few megabytes of stacks. But a goroutine is not a job. It dies when the process dies. It does not retry. It does not persist. It does not show up in a dashboard.
For fire-and-forget work that can live or die with the request, a goroutine is right.
// Rails: UserMailer.welcome(user).deliver_later
// Go: good enough if the email is non-critical
go func() {
ctx := context.Background()
if err := mailer.SendWelcome(ctx, user); err != nil {
log.Error("welcome email failed", "user", user.ID, "err", err)
}
}()
For anything durable, you reach for a queue library. The two serious choices in 2026 are Asynq, which is Redis-backed and Sidekiq-shaped, and River, which is Postgres-backed and integrates into the same transaction as your business writes. River is the one winning in teams that already run Postgres and do not want a second system to monitor.
// river: enqueue in the same transaction as the user insert
_, err := riverClient.InsertTx(ctx, tx, SendWelcomeArgs{UserID: u.ID}, nil)
The brain break is that the queue is no longer implicit. You do not inherit from ApplicationJob. You do not get a perform_later on every method. You explicitly define a job type, register a worker, and call an insert. More code. Less magic. Same reliability, once you write it.
Metaprogramming is gone
Ruby is a language where every runtime question is negotiable. method_missing exists. You can define a method by calling define_method inside a loop. You can open a class that lives in a gem and add a method to it. Rails DSLs, from has_many to validates to resources, are all Ruby methods that mutate class state at load time.
Go has none of this. There is no open-class. There is no runtime method definition. There are no DSLs, because there are no macros and no blocks. What you see in the file is what runs.
For a Rails developer, this is the single biggest culture shock. The expressive shortcut you reach for does not exist. A ten-line has_many :through relationship becomes, in Go, an explicit join query, an explicit return struct, and explicit population. The shortcut is gone, and with it, a class of bugs that nobody on a Rails team discusses because they are so baked into the day.
Rails surprises you at runtime. An undefined method on nil. A missing association. A NoMethodError from a gem you did not know patched your model. Go surprises you at compile time or not at all. The feedback loop shifts. You spend less time in debuggers. You spend more time arguing with the compiler before the program ever runs.
What you lose: the fluency of a DSL. Rails' ActiveModel validations are, line for line, less code than the equivalent Go struct tags plus a validator library plus handler wiring.
What you gain: a codebase that a new engineer can read top to bottom in a week without learning a metaprogramming tradition.
Rails tests to Go table-driven tests
Rails' default test story (minitest, fixtures, integration tests that boot the stack) is a local maximum. It works because the framework has one opinion about what a test harness is. RSpec added a second opinion on top, and there is a real argument for either.
Go tests do not look like either. There is no describe, no context, no before(:each). There is a function named TestSomething(t *testing.T). That is the whole API.
The idiom is the table-driven test.
func TestDiscount(t *testing.T) {
tests := []struct {
name string
cart Cart
coupon string
want int
}{
{"no coupon", Cart{Total: 10000}, "", 10000},
{"percent off", Cart{Total: 10000}, "SAVE10", 9000},
{"expired", Cart{Total: 10000}, "EXPIRED", 10000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Apply(tt.cart, tt.coupon)
if got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
No expectations library. No test doubles framework. No fixture system. You build the data you need in the test, you call the function, you compare. When you want isolation, you pass an interface and a fake. When you want HTTP, you use httptest.NewServer. The whole harness fits in an afternoon of reading.
A Rails developer used to let(:user) { create(:user) } will find this verbose at first. A year in, they usually stop missing the factory system, because the tests are simpler to read when the setup is inline.
Duck typing to explicit interfaces
Ruby: if it quacks, it's a duck. You pass anything that responds to .call or .each, and the caller never knows or cares about the type.
Go: if it quacks, you need to have written down, somewhere, an interface { Quack() }, and the parameter needs to be typed to it.
The shock is that Go interfaces are satisfied implicitly. You do not declare implements. Any type with the right methods counts. This is duck typing with a compile-time contract. Your type will not be accepted if it is missing a method, and you will find out at build time, not when the production request hits the line.
// the contract
type UserStore interface {
Get(ctx context.Context, id int64) (User, error)
Save(ctx context.Context, u User) error
}
// the handler does not care which store
func NewUserHandler(s UserStore) *UserHandler { ... }
// production: postgres-backed
h := NewUserHandler(pg.NewUserStore(db))
// test: in-memory fake
h := NewUserHandler(&fakeStore{})
Rails gets the same testability through stubbing. Go gets it through explicit interfaces at the seams you care about. The code is longer. The dependency graph is legible.
Generators to "write the file"
rails g model User email:string wrote the migration, the model, the test, the factory, the fixture. You ran one command and you had a scaffold.
Go has no generators subsystem. You write the file. Some projects template a handler with a shell alias; most do not bother. The file you create is a struct, a function, a test, and its registration. The cost is five minutes. The benefit, which nobody admits out loud in the Rails world, is that you cannot ship a half-generated file that you did not actually read.
This is the smallest of the differences on paper, and one of the larger ones in daily practice.
Why Shopify and GitHub still run Rails
Be honest about the counter.
Shopify runs the largest Rails monolith on the planet and has said publicly, many times, that it is a competitive advantage for them. GitHub is Rails. Basecamp, the company where Rails was born, ships multiple products from a single Rails app and intends to keep doing so. These are not startup teams. They have every option. They chose Rails and doubled down.
The reason is the same reason Rails wins a weekend project. Rails is the fastest path from an idea to a working application that other engineers can read. Convention, ActiveRecord, asset pipeline, generators, and the test harness compose into a productivity machine that Go does not try to be. A senior Rails engineer can add a feature to a ten-year-old monolith in an afternoon. A senior Go engineer can add a feature to a four-year-old Go service in an afternoon, but the four-year-old Go service probably does less than the ten-year-old Rails monolith, because the Go service was built smaller on purpose.
Where the Rails shops do migrate pieces to Go, the boundary is always the same. Hot paths that need to hold a lot of concurrent connections, services where the latency floor matters, places where a single binary is operationally cheaper than a fleet of Puma workers. Shopify's storefront renderer, written in Ruby, had its edge pieces rewritten in other languages for exactly this reason. The monolith stays. The 3% of the system that dominates the cost graph moves.
Ruby 3.3 and the YJIT work also changed the math since 2022. YJIT on a real Rails workload now gives 30 to 40% fewer instructions retired per request, and Shopify's own numbers from late 2025 put the storefront render path at roughly 2x faster than it was on Ruby 2.7. A lot of the "Rails is slow" instinct is older than the language it's measuring. If your Rails workload is web-request shaped and not extreme-fan-out shaped, the language itself is not the reason to leave.
A Rails controller and its Go equivalent
The shape of the migration lands when you see the same endpoint in both languages.
Rails:
# app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
before_action :authenticate!
def show
user = User.find(params[:id])
render json: {
id: user.id,
email: user.email,
posts_count: user.posts.count
}
end
end
Go:
// internal/http/users.go
func (h *UserHandler) Show(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
user, err := h.queries.GetUser(ctx, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "internal", http.StatusInternalServerError)
return
}
count, err := h.queries.CountPostsByUser(ctx, userID)
if err != nil {
http.Error(w, "internal", http.StatusInternalServerError)
return
}
w.Header().Set("content-type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": user.ID,
"email": user.Email,
"posts_count": count,
})
}
Same endpoint. Four times the lines. Every line does something you would have had the framework do for you in Rails.
In exchange: the request context is threaded all the way through (cancellable at any point), the SQL is visible and tunable, the error paths are explicit, and nothing is happening behind your back in a middleware chain you forgot existed. If this handler is slow in production, you can reason about why by reading the function.
What breaks your brain
The part a Rails developer does not expect is how much time they spend making decisions the framework used to make.
Router? net/http 1.22+, chi, gin, or echo. Pick one.
Config? envconfig, viper, or roll your own. Pick one.
Logging? log/slog, zap, or zerolog. Pick one.
Validation? go-playground/validator, ozzo-validation, or manual. Pick one.
Migrations? goose, golang-migrate, or atlas. Pick one.
Each pick is small. Five of them on week one adds up. A Rails team starts with rails new and writes the app. A Go team starts with an empty go.mod and writes the skeleton, then the app. The skeleton work is real, and it is the reason most companies that went Go have a shared internal library that answers the "pick one" questions once, so no team has to have that conversation again.
The second thing that breaks your brain is error handling. Every call returns (value, err). You check the error. You wrap it with context. You return it up. After a week it becomes muscle memory. In year two you realize you now know, for every failure path in your service, exactly where it originates and exactly how it got to the user. Rails gave you rescues. Go gives you a paper trail.
What Rails still does better
Rapid prototyping. A working app with models, migrations, and views on day one is a thing Rails does that Go does not, and the gap is not closing.
Developer ergonomics for small teams. A four-person Rails team ships faster than a four-person Go team for the first year of any project. The cost curve eventually crosses; the first year, it doesn't.
The feeling of a full framework. Rails has views, mailers, a cache, background jobs, an asset pipeline, and an ORM all shipped by the same team with the same philosophy. Go has none of that bundle. You are building the bundle yourself, and the bundle you build is smaller than Rails's on purpose.
If your service is a CRUD product with a web UI, a modest load profile, and a team that already knows Rails, the Go rewrite is usually the wrong call. If your service is a high-concurrency backend, a single binary in a tight SLA, or a box you want to deploy without a runtime, Go starts to pay for itself by the end of year one.
The honest version of "Rails to Go" is not a religious war. It's a staffing and workload question. Rails monoliths at Shopify's scale are alive and well. Go services at Shopify's scale are also alive and well. They sit in the same diagrams.
You don't rewrite the framework. You rewrite the part that needed to leave it.
flowchart TD
subgraph AR["ActiveRecord"]
Q1[Post.where active: true] --> SQL1[SQL at runtime]
SQL1 --> RES1[Hash-like objects]
end
subgraph SQLC["sqlc"]
FILE[query.sql file] --> GEN[sqlc generate]
GEN --> CODE[Typed Go fn]
CODE --> RES2[struct Post]
end
If this was useful
The Go half of this is covered in depth in Thinking in Go, the two-book series. Complete Guide to Go Programming is the language foundation, the one to start with if the handler above was your first serious look at Go. Hexagonal Architecture in Go is the service shape that keeps a Go codebase readable as it grows past the skeleton — the book that answers the "how do I not end up with a Rails-shaped Go app" question.
Observability for LLM Applications is the book that pays for the Go side when what you're running in production is an LLM service: tracing, cost accounting, and the production-readiness work that a Rails team used to get for free from the framework.
- Thinking in Go (2-book series): Complete Guide to Go Programming · Hexagonal Architecture in Go
- Book: Observability for LLM Applications — paperback and hardcover · Ebook from Apr 22
- Hermes IDE: hermes-ide.com · GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com · GitHub


Top comments (0)