I Moved From Go to FastAPI. It Felt Suspiciously Easy.
DEV Community

I Moved From Go to FastAPI. It Felt Suspiciously Easy.

For quite a while, most of my backend code was written in Go. I built projects like Logiflow, a logistics backend with PostgreSQL, Redis, WebSockets, OSRM and monitoring, and Sentinel, an event-driven fraud detection project using Kafka and PostgreSQL. At some point I also got tired of setting up the same Go project structure over and over again, so I built gofro , a small scaffolding CLI for generating the boring parts. I got used to the way Go backend projects feel. You create the server, configure the router, initialize the database, construct dependencies, pass them around, handle errors and gradually connect everything together. Then I started building LinkUp with Python and FastAPI. My first impression was simple: This felt suspiciously easy. Not because the project itself was easier, but because a surprising amount of code I was used to writing simply disappeared. After spending some time with FastAPI, I started to understand where that complexity actually went. Where did all the plumbing go? A typical Go project of mine has a fairly explicit structure. cmd/ internal/ api/ config/ database/ handler/ pkg/ migrations/ Dockerfile Makefile I like this approach. If something exists in the application, I can usually find where it was created and where it was passed. Routes are registered somewhere. Dependencies are constructed somewhere. Database connections are opened somewhere. When I started using FastAPI, the same things looked very different. For example, you can define a Pydantic model: class RegisterRequest(BaseModel): email: EmailStr password: str = Field(min_length=8) Then use it directly in a route: @router.post("/register") async def register(data: RegisterRequest): ... FastAPI understands that data comes from the request body. Pydantic validates it, FastAPI uses the same information for OpenAPI, and serialization is handled for you. When I first saw this, I had to stop for a moment and figure out how FastAPI knew what I wanted. In Go I was used to seeing more of this process explicitly. FastAPI did not remove request parsing or validation. It just moved them behind conventions built around Python type annotations. That became a recurring theme while learning it. Less visible code does not necessarily mean less happening. Then I found Depends Dependency injection felt even stranger at first. In LinkUp I have dependencies for things like the database session, Redis and the currently authenticated user. A database dependency can be represented roughly like this: SessionDep = Annotated[ AsyncSession, Depends(get_session), ] Then another route or dependency simply asks for SessionDep . FastAPI resolves it. Coming from Go, I was much more used to something like this: server := NewServer(db, redis, logger) Everything is visible right there. With FastAPI, you look at a function parameter and the framework basically says: "Yeah, I know a guy." At first this felt like magic. After using it for a while, it started making more sense. The dependency graph still exists. FastAPI just lets me describe part of it declaratively instead of wiring every dependency manually. I still prefer understanding where those dependencies come from, but I no longer find the approach strange. Most of the time. async def does not make everything asynchronous This was probably the most important adjustment. Go gave me a fairly straightforward application-level model of concurrency. If I want to start another concurrent task, I can write: go doSomething() The runtime obviously does a lot behind that simple line, but goroutines are a normal part of how Go programs are written. Python async works differently. In FastAPI, it is very easy to look at this: async def login(...): ... and mentally classify everything inside it as asynchronous. That is not how it works. I ran into a good example while implementing authentication in LinkUp. Passwords are hashed with Argon2. Argon2 is intentionally expensive. Making password hashing computationally expensive is part of its job. But Argon2 is still synchronous CPU work. Putting that call inside an async def does not suddenly make it cooperate with the event loop. If the hashing operation runs directly on the event loop thread, the loop has to wait for it to finish. So in LinkUp, password hashing and verification are moved into a thread pool: async def hash_password(password: str) -> str: return await run_in_threadpool( password_hash.hash, password, ) This was a useful reminder that async is not a property that spreads through the entire function. An async PostgreSQL driver can yield while waiting for network I/O. Redis can yield. An HTTP client can yield. CPU-bound synchronous work cannot do that automatically. It sounds obvious once you understand the model, but an endpoint full of async keywords can make it easy to forget what is actually happening underneath. SQLAlchemy was familiar and unfamiliar at the same time The database side was another interesting part of the transition. The concepts were already familiar. A request needs a database session. Something fails, so the transaction should be rolled back. The session needs to be closed afterwards. None of that was new. The Python way of expressing it was. A simplified version of the LinkUp session dependency looks like this: async def get_session(): async with session_factory() as session: try: yield session except Exception: await session.rollback() raise FastAPI uses the code before yield as setup and the remaining part as teardown. I understood why the lifecycle worked this way before the syntax itself started feeling natural. That happened quite a lot while moving to Python. I was not relearning what a database transaction was. I was learning how the Python ecosystem usually expresses the same idea. That is a very different experience from learning backend development for the first time. The actual backend problems did not change much After the first few days, something else became obvious. Once I got past the syntax and framework conventions, most of the important questions were the same ones I already knew. LinkUp uses: - Argon2 password hashing - short-lived JWT access tokens - opaque refresh tokens stored in Redis - refresh token rotation - revocation on logout - HttpOnly cookies - PostgreSQL persistence The implementation is Python, but the questions are not Python-specific. When should a transaction commit? What happens if two registration requests try to create the same email? Where should refresh sessions live? Can a refresh token be reused? How should logout invalidate a session? What happens if a valid access token references a user that no longer exists? Python did not remove those problems. FastAPI just removed a lot of the repetitive framework code around them. That was probably the biggest change in how I looked at the framework. At first, fewer lines of code made the whole thing look simpler. Later I realized that the engineering was still there. The ratio between framework code and application code had changed. Go made me notice hidden complexity I think starting with Go affected the way I reacted to FastAPI. Go made me used to seeing plumbing. My Go projects tend to be fairly explicit about HTTP handlers, database access, context propagation, errors and infrastructure. Sentinel goes even further with Kafka consumers, explicit delivery semantics, worker pools and transactional persistence. I eventually wrote gofro because I had repeated enough of the usual setup that generating part of it became convenient. Then I moved to FastAPI and suddenly request validation, dependency injection, OpenAPI generation and quite a lot of HTTP plumbing required very little code. My first instinct was to look for what I was missing. In practice, the abstraction is usually fine. I just need to know what it is doing. An async def endpoint can still contain blocking code. An ORM can still produce an inefficient query. A dependency can have the wrong lifecycle. A Pydantic schema can model the wrong contract. A transaction can still be committed at the wrong time. FastAPI does not solve backend engineering. It mostly saves me from writing some of the repetitive parts of it. I like that distinction much more than simply saying FastAPI is "easier." There are still things I miss about Go Python is much more concise for a lot of application code. There are places in my Go projects where the equivalent Python implementation would probably be much smaller. There is less repetitive error handling, less manual DTO plumbing and generally less code needed to express common web application behavior. But explicitness has advantages too. In Go, this is boring: if err != nil { return err } especially after seeing it for the thousandth time. But I always know where the failure is being handled. In FastAPI, a small route can depend on several other functions, and part of its behavior may live somewhere deeper in that dependency graph. SQLAlchemy can express a query very compactly, but I still need to care about the SQL it eventually generates. Neither approach feels universally better to me. They just put complexity in different places. The part I liked most was realizing I did not start from zero This was probably the nicest part of switching stacks. My Python fluency was much lower than my familiarity with the backend problems I was trying to solve. Sometimes I had to look up a Python construction while already knowing exactly what I wanted the application to do. I could forget the syntax for an async generator dependency while still understanding why the database session needed cleanup. I could be unfamiliar with a particular SQLAlchemy API while already understanding the transaction boundary I wanted. I could still be getting used to asyncio while knowing that running Argon2 directly on an event loop was a bad idea once I understood how the loop worked. The language changed. The framework changed. The libraries changed. PostgreSQL did not. Redis did not

Comments

No comments yet. Start the discussion.