DEV Community

Cover image for Python to Go: A Django Developer's Survival Guide
Gabriel Anhaia
Gabriel Anhaia

Posted on

Python to Go: A Django Developer's Survival Guide


You have a Django app. It works. It is slow under load. You keep adding uWSGI workers. The EC2 instance keeps getting bigger. A colleague keeps mentioning Go. You nod, you say "yeah, one day," and you push the conversation down the road.

This post is what that colleague wishes they had sent you six months ago.

It is written for the developer whose reflexes are Django, Flask, or FastAPI. The person who knows what select_related does, who has opinions about Pydantic v2, who still mistypes gunicorn on the third try. The goal is not to convince you that Go is better than Python. The goal is to get you to productive Go in a week instead of a quarter, by telling you which of your Python habits translate cleanly, which ones you need to unlearn, and where Python actually still wins.

flowchart TD
    subgraph PY["CPython + GIL"]
        T1[Thread A] -.->|waits for GIL| G[GIL lock]
        T2[Thread B] -.->|waits for GIL| G
        T3[Thread C] -.->|waits for GIL| G
        G --> RUN[Only one runs at a time]
    end
    subgraph GO["Go runtime"]
        GR1[Goroutine A] --> CORE[CPU core 1]
        GR2[Goroutine B] --> CORE2[CPU core 2]
        GR3[Goroutine C] --> CORE3[CPU core 3]
    end
Enter fullscreen mode Exit fullscreen mode

The shape of a web service, side by side

Start with the thing you write every day. A request handler that takes an authenticated user, looks up a record, returns JSON.

Django:

# views.py
from django.http import JsonResponse
from django.views.decorators.http import require_GET
from .models import Invoice

@require_GET
def invoice_detail(request, invoice_id):
    invoice = Invoice.objects.select_related("customer").get(
        id=invoice_id, owner=request.user
    )
    return JsonResponse({
        "id": invoice.id,
        "amount_cents": invoice.amount_cents,
        "customer": invoice.customer.name,
    })
Enter fullscreen mode Exit fullscreen mode

Go, using the standard library and sqlc-generated queries:

// handler.go
func (h *Handlers) InvoiceDetail(w http.ResponseWriter, r *http.Request) {
    userID := auth.UserIDFromContext(r.Context())
    id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    if err != nil {
        http.Error(w, "bad id", http.StatusBadRequest)
        return
    }

    inv, err := h.q.GetInvoiceForOwner(r.Context(), db.GetInvoiceForOwnerParams{
        ID: id, OwnerID: userID,
    })
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    json.NewEncoder(w).Encode(map[string]any{
        "id":           inv.ID,
        "amount_cents": inv.AmountCents,
        "customer":     inv.CustomerName,
    })
}
Enter fullscreen mode Exit fullscreen mode

Same request, same response. Three things changed.

The routing is not decorator-based. You register the handler on a mux (http.ServeMux since Go 1.22 supports method and path variables). There is no magic URL dispatcher scanning modules.

The ORM is gone. h.q.GetInvoiceForOwner is a function generated by sqlc from a SQL file you wrote by hand. The JOIN that Django did through select_related is explicit in your SQL. This feels like a step backwards for a week, and then it feels like a step forward forever.

The authenticated user is not on request.user. It is a value threaded through r.Context() by the middleware that did the auth. context.Context is the most important type in Go backend code, and Django has no real equivalent.

The ORM question: sqlc vs GORM

If you were writing Go five years ago, GORM was the answer. You defined structs, it built queries, you kept your Django muscle memory. Today the consensus on production Go teams has shifted. sqlc is what I would reach for and what I would recommend to a team coming from Django.

The pitch is narrow. You write SQL in .sql files. sqlc reads your schema, reads the queries, and generates typed Go functions. The generated code compiles. If you rename a column, the generator catches it before your service does.

-- query.sql
-- name: GetInvoiceForOwner :one
SELECT i.id, i.amount_cents, c.name AS customer_name
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.id = $1 AND i.owner_id = $2;
Enter fullscreen mode Exit fullscreen mode

After sqlc generate, you have a Go function with typed parameters and a typed return. No reflection, no query builder, no N+1 hidden behind a __str__ that triggers a lazy load. What the database does is what the file says the database does.

GORM still has its place. If your team wants Django-style auto-migrations, polymorphic associations, and a familiar .Where().First() chain, GORM will feel like home. The trade you are making is correctness-by-compilation for familiarity. Do it knowingly. Most teams I see regret the GORM choice around month three, once their query logic has grown.

Middleware: from decorators to composed handlers

Django middleware is a class that wraps the request in __call__. FastAPI middleware is a callable registered on the app. Both hide the wrapping mechanism.

Go does not hide it. Middleware is a function that takes a handler and returns a handler.

func WithAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        userID, err := verifyToken(token)
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), userIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func WithRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.NewString()
        ctx := context.WithValue(r.Context(), reqIDKey, id)
        w.Header().Set("X-Request-Id", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

mux := http.NewServeMux()
mux.HandleFunc("GET /invoices/{id}", h.InvoiceDetail)
http.ListenAndServe(":8080", WithRequestID(WithAuth(mux)))
Enter fullscreen mode Exit fullscreen mode

Read the last line. The request goes through WithRequestID, then WithAuth, then the mux, then the handler. The composition is text you can point at. There is no MRO, no registry, no ordering bug hiding in a settings file.

If you want router ergonomics closer to Django or FastAPI, chi and echo are the two popular choices. They wrap the same stdlib primitives and add grouping, path params, and middleware chaining with less ceremony. I would still start on http.ServeMux in 2026. Go 1.22 raised the stdlib router to a level where most apps do not need a framework.

Validation: Pydantic goes, struct tags come in

Pydantic is the single feature that keeps some FastAPI developers on Python. In Go you get a different deal. Types at the language level, validation at the struct-tag level, both doing work the Pydantic model does in one place.

type CreateInvoiceRequest struct {
    CustomerID  int64  `json:"customer_id" validate:"required,gt=0"`
    AmountCents int64  `json:"amount_cents" validate:"required,gt=0,lte=100000000"`
    Currency    string `json:"currency" validate:"required,oneof=USD EUR GBP"`
    Memo        string `json:"memo" validate:"max=280"`
}

func (h *Handlers) CreateInvoice(w http.ResponseWriter, r *http.Request) {
    var req CreateInvoiceRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }
    if err := h.validate.Struct(req); err != nil {
        http.Error(w, err.Error(), http.StatusUnprocessableEntity)
        return
    }
    // req is now trusted. Use it.
}
Enter fullscreen mode Exit fullscreen mode

The library is go-playground/validator. The rules you know from Pydantic (min, max, email, oneof) all exist. Custom validators are a function you register. Error messages are a method you implement.

The thing Pydantic gives you that Go does not is automatic schema documentation. If your Django team is used to drf-yasg or FastAPI's auto-generated OpenAPI, you will want oapi-codegen in your Go stack. Write the OpenAPI spec, generate the types and the handler interface, write the handlers against that interface. The direction of code generation flips. You stop deriving docs from code and start deriving code from docs. For a team migrating off Django, this is often the cleanest seam to put the Go service behind.

Concurrency: from GIL to goroutines

This is the translation that will reshape how you think about a service.

In Django, parallel work inside a request means Celery, or a thread pool you were scared to touch. In FastAPI, it means asyncio.gather and painting every function async. Both are workarounds for one fact: the GIL means Python threads cannot run Python bytecode in parallel.

Python 3.13 shipped an experimental free-threaded build that lifts the GIL. It is real progress. It also is not yet the default, most C extensions are not ready, and the throughput gains on real workloads are still being measured. For the workload this post is about, a web service fanning out to IO, Go still wins by a margin that is not close.

func (h *Handlers) Dashboard(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    userID := auth.UserIDFromContext(ctx)

    var (
        invoices []Invoice
        billing  BillingSummary
        usage    Usage
    )
    g, gctx := errgroup.WithContext(ctx)

    g.Go(func() (err error) {
        invoices, err = h.invoiceSvc.Recent(gctx, userID)
        return
    })
    g.Go(func() (err error) {
        billing, err = h.billingSvc.Summary(gctx, userID)
        return
    })
    g.Go(func() (err error) {
        usage, err = h.usageSvc.Current(gctx, userID)
        return
    })

    if err := g.Wait(); err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    json.NewEncoder(w).Encode(map[string]any{
        "invoices": invoices,
        "billing":  billing,
        "usage":    usage,
    })
}
Enter fullscreen mode Exit fullscreen mode

Three IO calls, three goroutines, one cancellation context. If the client disconnects, ctx cancels, errgroup's derived context cancels, all three goroutines get the signal on their next network op. If one fails, the other two get cancelled too. You did not write a single async keyword. There is no colored-function problem.

The asyncio equivalent works. It also requires every library in the call graph to be async-aware, a rule you will break the first time a well-meaning junior imports requests instead of httpx.

Dependency injection: Depends goes, constructors come in

FastAPI Depends and Django's middleware-plus-signal pattern both hide a graph behind a decorator. In Go, the graph is code.

type Handlers struct {
    q        *db.Queries
    validate *validator.Validate
    billing  BillingService
    log      *slog.Logger
}

func NewHandlers(pool *pgxpool.Pool, billing BillingService, log *slog.Logger) *Handlers {
    return &Handlers{
        q:        db.New(pool),
        validate: validator.New(),
        billing:  billing,
        log:      log,
    }
}

func main() {
    ctx := context.Background()
    pool, _ := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
    defer pool.Close()

    h := NewHandlers(pool, stripe.New(os.Getenv("STRIPE_KEY")), slog.Default())

    mux := http.NewServeMux()
    mux.HandleFunc("GET /invoices/{id}", h.InvoiceDetail)
    http.ListenAndServe(":8080", WithRequestID(WithAuth(mux)))
}
Enter fullscreen mode Exit fullscreen mode

The handler struct holds its dependencies. main wires them up. Testing a handler means constructing it with fakes. There is no container, no registry, no call to app.dependency_overrides.

This looks like a lot of ceremony on day one. By day thirty, every developer on the team can answer "where does this handler get its database" by reading two files. The graph is discoverable without a framework. When you migrate a second service off Django, the same pattern scales.

Teams that want more than plain constructors reach for wire or fx. Both generate or resolve the graph for you. Neither is necessary on a service with fewer than thirty types. Start explicit. Add a framework only when the wiring is actually painful.

Five Python habits to unlearn

These are the patterns I watch Django and FastAPI developers over-index on in their first month of Go. Each one has a cleaner Go shape.

1. Reaching for a framework for everything. Django solves routing, ORM, auth, admin, migrations, and templating in one package. Go does not have that package, and it is not coming. Your new stack is net/http + sqlc + goose (migrations) + a logging library. It is five pieces. Compose them yourself.

2. Treating errors as exceptions. try/except does not exist. Every function that can fail returns (value, error). You check the error on the line after the call. This is verbose, and it is the point. Go's error discipline is the single biggest reliability gain a team gets from the migration. Resist the urge to wrap everything in a generic HandleError helper that swallows context.

3. Writing clever generic types. Go has generics since 1.18. Use them for data structures and narrow utility functions. Do not rebuild SQLAlchemy's TypeVar machinery in Go. If the function signature takes three type parameters, you probably want an interface and a concrete type instead.

4. Living in the REPL. Python's iteration loop is python -i, ipython, or the Django shell. Go does not ship a REPL worth using. Your new loop is go test ./... with fast, small tests. A new Go developer who leans into test-driven inner loops is productive in two weeks. One who waits for a REPL that never comes is stuck for two months.

5. Keeping requirements.txt mental models. go.mod and go.sum resolve the dependency graph once, at compile time, and bake the result into the binary. There is no virtualenv, no pip install -r, no runtime import that pulls down a different version than CI tested. The binary you build is the artifact. Deploying is scp plus systemctl restart, and that is a feature.

What Python still does better

Writing a post like this without a counter is dishonest. These are the places I would still reach for Python on a brand-new project in 2026.

Data science and ML. PyTorch, NumPy, pandas, scikit-learn, the research community, the models, the papers. None of it is in Go, none of it is coming to Go, and the gap is widening. If your service touches training, fine-tuning, or embeddings at the modeling layer, Python is the right tool and that is not a close call.

Scripting velocity. A 40-line Python script that parses a CSV, hits an API, and writes a report is faster to write, faster to iterate on, and easier to hand to a non-engineer than the Go equivalent. For one-shot work and internal tools, Python still wins on raw developer seconds.

Notebooks. Jupyter has no Go equivalent worth mentioning. If your team needs to explore data interactively with plots, Python.

Web scraping with messy state. BeautifulSoup plus playwright-python is still easier than Colly plus chromedp when the target site is hostile and the logic is experimental.

The shape of the argument is that Python owns the parts of the stack that look like research, exploration, and data. Go owns the parts that look like a service handling user traffic. A mature team runs both, and the migration is not "Python out, Go in." It is "Python where Python is strong, Go where Go is strong, a boundary you can draw on a whiteboard."

Where to start

If your Django app is one monolith, do not rewrite it. Pick the one endpoint that wakes you up at night. The fan-out one, the slow one, the one that pins your uWSGI workers. Extract it behind a reverse proxy. Write the Go version against the same database. Run both in parallel for a week. Measure.

You will find out two things. The Go service holds more load on smaller hardware than you expected. And the parts of your Django app that were carrying you, admin, migrations, the ORM's query planner, were carrying you for real, and you will miss them. Neither observation changes the conclusion. It changes where you draw the line.

Your colleague was right about Go. They were also right that you did not have to rewrite everything. You had to pick the right service, and write it the Go way, not the Django way in Go syntax.


flowchart TD
    subgraph DJ["Django stack"]
        N1[nginx] --> U[uWSGI workers x N]
        U --> W1[Django app]
        W1 --> ORM[Django ORM]
        ORM --> DB1[(PostgreSQL)]
    end
    subgraph GO["Go stack"]
        N2[reverse proxy] --> S[single Go binary]
        S --> CHI[chi router]
        CHI --> SQLC[sqlc queries]
        SQLC --> DB2[(PostgreSQL)]
    end
Enter fullscreen mode Exit fullscreen mode

If this was useful

The book this post is orbiting on the Go side is Thinking in Go, a 2-book series that covers the language from the ground up and then the architecture patterns for real services. The hexagonal book in particular is aimed at exactly the developer this post is aimed at: someone with a working backend brain who wants the Go way of drawing the seams.

On the Go-for-AI side, Observability for LLM Applications is where the agent-service code you would write after this post gets instrumented.

Thinking in Go — 2-book series on Go programming and hexagonal architecture

Observability for LLM Applications — the book

Top comments (0)