DEV Community

Pradip Parmar
Pradip Parmar

Posted on

Why We Migrated a Python Service to Go: Lessons from the MakeMyTrip Flights Backend

At MakeMyTrip, I led the migration of the Flights backend from Python to Go. The result: resource usage dropped by 60%, and our AWS costs went down with it.

Let me be upfront about what this post is and isn't. The internal details of that system aren't mine to publish, so you won't find architecture diagrams or traffic numbers here. What I can share is why Go is a good fit for that kind of service, the Go patterns that typically produce savings like this, and the migration practices I recommend. I've done this kind of move more than once: earlier, at THB, I moved a medical-data sync service from Node.js to Go and its ETL time went from 3 hours to 10 minutes. At Talentica, working on PlanetScale's Vitess, I migrated Python test cases to Go.

Why a flights backend suits Go

A flight search or booking backend is mostly I/O: calling upstream services, waiting, combining responses, caching (we used Redis), and serving REST APIs. That workload pays off in Go for a few reasons.

Cheap concurrency. A goroutine starts with a few kilobytes of stack, and the Go runtime schedules many of them across all CPU cores in one process. In a typical Python web deployment, each worker process handles a limited number of concurrent requests, so you scale by adding processes, and each process carries its own copy of the interpreter and your application's memory. Go serves the same concurrency from one process per instance.

Predictable memory. Go structs are compact and laid out contiguously. Python objects carry more per-object overhead. For services that hold lots of small records in memory, such as fares, segments or cached responses, that difference adds up.

Real parallelism for CPU work. Parsing, merging and sorting large JSON responses is CPU-bound. Go uses every core without multiprocessing workarounds.

Simple deployment. A static binary in a small container image. No virtualenvs, no dependency drift between machines.

Less CPU and memory per request means fewer or smaller instances for the same load. That is where a resource cut like 60% turns into a lower AWS bill.

A core Go pattern for aggregation services: bounded fan-out

The typical hot path in an aggregation service calls several upstreams at once and merges the results. In Go, errgroup makes this concise and safe. (The code samples in this post are simplified illustrations, not MakeMyTrip's code.)

import "golang.org/x/sync/errgroup"

type Quote struct {
    Supplier string
    Fares    []Fare
}

func (s *Service) Search(ctx context.Context, q SearchQuery) ([]Fare, error) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    results := make([]Quote, len(s.suppliers))
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(8) // bound concurrency per request

    for i, sup := range s.suppliers {
        g.Go(func() error {
            fares, err := sup.Search(ctx, q)
            if err != nil {
                s.log.Warn("supplier failed", "supplier", sup.Name(), "err", err)
                return nil // a partial result beats no result
            }
            results[i] = Quote{Supplier: sup.Name(), Fares: fares}
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return merge(results), nil
}
Enter fullscreen mode Exit fullscreen mode

The details matter:

  • One deadline for the whole request, passed via context, so slow upstreams are cut off and their goroutines exit.
  • SetLimit bounds concurrency, so a traffic spike doesn't become thousands of outbound connections.
  • Writing to results[i] avoids a mutex: each goroutine owns its own index.
  • Partial failure is a decision, not an accident. Here one supplier failing is logged and skipped.

(Since Go 1.22, each loop iteration has its own i and sup, so capturing them in the closure is safe.)

Small things that cut memory and CPU

Moving to Go gives you a better baseline. These habits keep it lean:

// Reuse HTTP connections: one shared client with a tuned transport.
var upstream = &http.Client{
    Timeout: 3 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        200,
        MaxIdleConnsPerHost: 50,
        IdleConnTimeout:     90 * time.Second,
    },
}

// Stream-decode large responses instead of reading them into memory first.
func decodeFares(r io.Reader) ([]Fare, error) {
    var payload struct {
        Fares []Fare `json:"fares"`
    }
    err := json.NewDecoder(r).Decode(&payload)
    return payload.Fares, err
}
Enter fullscreen mode Exit fullscreen mode

The default MaxIdleConnsPerHost is 2, which causes constant reconnects to a busy upstream. That one setting is easy to miss after a migration. Then measure, don't guess: net/http/pprof on an internal port shows where CPU and allocations go.

How to migrate without breaking production

A rewrite is risky. A migration of a service people depend on is riskier. The practices I recommend:

1. Freeze the contract first. Write down the existing API's request and response formats, including the odd parts. Then write contract tests against the Python service and run the same tests against the Go one.

2. Watch for JSON differences. Most migration bugs are serialization differences, not logic errors:

type FareResponse struct {
    // Python returned null when missing; a Go string would return "".
    PromoCode *string `json:"promo_code"`
    // Python may omit a key entirely; match that with omitempty only if clients expect it.
    Baggage   []Bag   `json:"baggage,omitempty"`
    // Keep money as integer minor units; don't let float formatting change.
    TotalPaise int64  `json:"total_paise"`
}
Enter fullscreen mode Exit fullscreen mode

Go's zero values ("", 0, false, [] vs null) differ from Python's None. Decide field by field, and test it.

3. Compare responses before switching. Send the same requests to both implementations and diff the outputs, ignoring fields that legitimately differ, such as timestamps:

func diffJSON(a, b []byte, ignore map[string]bool) (bool, error) {
    var x, y map[string]any
    if err := json.Unmarshal(a, &x); err != nil {
        return false, err
    }
    if err := json.Unmarshal(b, &y); err != nil {
        return false, err
    }
    for k := range ignore {
        delete(x, k)
        delete(y, k)
    }
    return reflect.DeepEqual(x, y), nil
}
Enter fullscreen mode Exit fullscreen mode

4. Migrate endpoint by endpoint. Put a router in front and move routes one at a time, starting with read-only, high-volume endpoints where the resource savings are largest and the risk is smallest.

5. Keep a rollback switch. Routing back to the old service should be a config change, not a deploy.

Lessons beyond performance

The same period at MakeMyTrip taught me that code structure matters as much as runtime efficiency. I also worked on the Unification Project, building shared libraries used by both MakeMyTrip and Goibibo. That cut development costs by 40%, and over the same period we cut average feature delivery time from 1.5 months to 20 days across both brands. A migration is a good moment to pull repeated logic (clients, retries, caching, logging) into shared Go packages, so the next service starts from a better place.

When not to migrate

Go isn't automatically the answer. If a service is small, cheap to run and rarely changes, a rewrite's risk outweighs its savings. If the work is mostly data science or depends on Python-only ML libraries, keep it in Python and put a Go service in front of it if you need one. Migrate when the service is I/O- or concurrency-heavy, costs real money to run and will be maintained for years.

Takeaways

  • I/O-heavy, high-concurrency services are where Go saves the most.
  • Bounded fan-out with errgroup and context deadlines is the core pattern.
  • Most migration bugs are contract and serialization bugs. Test the contract.
  • Move endpoint by endpoint, with a rollback switch.

Pradip Parmar is a Go backend and AI agent engineer from Surat, India, who has written Go since 2018. At MakeMyTrip he led the Python-to-Go migration of the Flights backend, and today he builds Go backends, RAG systems, MCP servers and LLM agents for businesses as an independent engineer. Contact him on LinkedIn, Upwork or Contra, or browse GitHub.

Top comments (0)