Disclaimer: I like Python. I’m not here to bash it — I’m here to talk about the challenges I’ve personally faced while using it to build something real, at scale. If you're a Python dev, I hope this resonates with you (or challenges you — that’s welcome too).
🚀 Python is Great... Until It’s Not
Python is beloved for good reason. It’s clean, expressive, and beginner-friendly. The syntax almost reads like English, and you can go from idea to working prototype insanely fast.
But once your application grows — more services, more developers, more requirements — things start to get messy. And Python doesn’t do much to stop you from falling into that mess.
💥 Real-Life Pain: FastAPI in a Growing Project
I’m building a project with FastAPI, and I was excited at first. It’s async-ready, Pydantic-powered, and comes with auto-generated Swagger docs. What’s not to love?
But as the app grew, problems showed up fast:
- 🧩 Abstractions are hard. FastAPI’s dependency injection system becomes unwieldy at scale. DI feels more like a workaround than a core feature.
- 🔄 Type safety isn’t enforced. Pydantic gives runtime validation — but not compile-time safety. You don’t get warned about mistakes until you hit the endpoint.
- 🐛 Refactoring is scary. Rename a field, miss a usage somewhere? No compile-time errors. It breaks silently.
- 🛠️ Tooling is decent, but not great. VSCode helps, but it doesn't match the instant feedback of
tsc
or Go’s compiler.
Let me show you an example that made me stop and reconsider Python as a backend language for large systems.
📦 The Service Layer Pattern — In 3 Languages
We’ll look at a simple flow:
- Get a user from the database by ID.
- Return a user DTO.
- Catch an error if the user doesn’t exist.
🐍 Python (FastAPI + Pydantic)
# user_repository.py
class UserRepository:
def get_by_id(self, user_id: int):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise Exception("User not found")
return user
# user_service.py
class UserService:
def __init__(self, repo: UserRepository):
self.repo = repo
def get_user(self, user_id: int):
return self.repo.get_by_id(user_id)
# main.py
@app.get("/users/{user_id}")
def get_user(user_id: int):
service = UserService(UserRepository())
return service.get_user(user_id)
❌ No enforced types between layers.
❌ If you change the return type of get_by_id
, nothing tells you.
❌ Errors are just exceptions — no clear contract.
✅ TypeScript (NestJS)
// user.entity.ts
export interface User {
id: number;
name: string;
}
// user.repository.ts
@Injectable()
export class UserRepository {
getById(id: number): User {
const user = db.find(u => u.id === id);
if (!user) throw new NotFoundException();
return user;
}
}
// user.service.ts
@Injectable()
export class UserService {
constructor(private readonly repo: UserRepository) {}
getUser(id: number): User {
return this.repo.getById(id);
}
}
// user.controller.ts
@Controller("users")
export class UserController {
constructor(private readonly service: UserService) {}
@Get(":id")
getUser(@Param("id", ParseIntPipe) id: number): User {
return this.service.getUser(id);
}
}
✅ Type-safe across all layers — change the return type in one place, and TypeScript will scream.
✅ Clear contracts.
✅ IDE support and autocompletion are first-class.
✅ Easy to scale with modules and DI containers.
✅ Go (Gin or Fiber)
// user.go
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
// repository.go
func GetUserByID(id int) (*User, error) {
user := dbFindUserByID(id)
if user == nil {
return nil, errors.New("user not found")
}
return user, nil
}
// service.go
func GetUser(id int) (*User, error) {
return GetUserByID(id)
}
// handler.go
func GetUserHandler(c *gin.Context) {
idParam := c.Param("id")
id, _ := strconv.Atoi(idParam)
user, err := GetUser(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
✅ Compile-time safety — change the type, and the compiler helps you fix everything.
✅ Lightweight and fast.
✅ Great for performance-critical systems.
🔍 What This Shows
Feature | Python (FastAPI) | TypeScript (NestJS) | Go |
---|---|---|---|
Compile-time type safety | ❌ | ✅ | ✅ |
Enforced architecture | ❌ | ✅ | ✅ |
Refactor-safe | ❌ | ✅ | ✅ |
Dependency Injection | ✅ (loose) | ✅ (structured) | ✅ (manual) |
Runtime performance | ⚠️ Moderate | ⚠️ Moderate | ✅ Fast |
Dev experience at scale | ❌ | ✅ | ✅ |
🧘 Final Thoughts
This post isn’t about hating Python. It’s about knowing what you’re dealing with.
Python is phenomenal for ML, scripting, automation, and even small APIs. But for high-scale backend systems, I’ve found TypeScript (NestJS) and Go to be way more reliable, maintainable, and scalable — especially when working in teams, with lots of moving parts, over the long term.
What’s been your experience? Have you managed to scale a large Python codebase without pain? Or did you make the switch like I did?
Let me know. I’m open minded and love feedback.
Top comments (0)