About a year ago, I started Lazy Ninja with a clear goal: eliminate boilerplate in Django APIs.
The idea was simple in theory: automatically generate CRUD routes, validation, serialization, and documentation directly from Django models. Less repetition, fewer files to maintain, and a smoother developer experience.
I was involved early in the development of Arkos.js, a project built on top of Prisma and designed around a schema-first, code-generated approach. That experience strongly influenced how I initially thought about tooling and type safety.
While building Lazy Ninja, however, I ran into a problem that had little to do with framework design and everything to do with how far static analysis can really go.
The real tension
The core issue is not Python versus any other language.
It is the tension between static analysis and runtime-driven systems.
Lazy Ninja relies heavily on metaprogramming and runtime introspection. In practice, it needs to:
Inspect Django models at runtime
Discover fields via reflection (
_meta.get_fields())Generate Pydantic schemas dynamically
Create API routes automatically
From a runtime perspective, this is safe and predictable. Django guarantees the existence of model fields, primary keys, and relationships.
From a static analysis perspective, however, things start to break down.
Why static analysis struggles with runtime-driven design
Static type checkers such as Pylance and mypy analyze code without executing it. They operate under a fundamental constraint: they can only reason about information that exists before runtime.
When a system builds part of its structure dynamically, static analysis simply cannot observe that process.
A minimal example:
User = get_user_model() # type unknown to the checker
user.email # attribute may not exist (statically)
# At runtime, Django guarantees that User has an email field
This is not a bug in the type checker.
It is a limitation of static analysis itself.
The contract exists — but it exists only at runtime.
A more realistic example from metaprogramming
Consider a simplified version of how Lazy Ninja inspects models:
def extract_fields(model):
return [
field.name
for field in model._meta.get_fields()
if not field.is_relation
]
At runtime, this works flawlessly.
Django’s model system ensures _meta is present and fully populated.
From the type checker’s point of view:
_metais dynamicget_fields()returns heterogeneous field typesThe structure is opaque
The result is warnings — even though the behavior is correct.
The temptation to “fight” the type checker
Early on, it’s tempting to try to make the type checker happy at all costs:
Overusing
cast()Adding fake base classes
Writing overly generic
AnyannotationsHiding logic behind layers of indirection
In practice, this often makes the code harder to read and less honest about what is happening.
At some point, I realized the better question was not:
“How do I silence the type checker?”
But rather:
“Where exactly does static analysis stop being useful?”
When static analysis works extremely well
Static analysis shines when the data contract is known before execution.
Schema-first systems that generate code ahead of time give static analyzers full visibility into models, fields, and relationships. This is why tools like Prisma Client Python can offer strong type safety in Python: the client is generated from a schema, not inferred from runtime behavior.
In those systems:
Autocomplete works perfectly
IDEs understand relationships
Many bugs are caught before execution
This reinforces an important idea:
The limitation is not the language, but when the contract exists.
Why Lazy Ninja does not adopt a schema-first approach
I seriously considered a schema-first model.
However, Lazy Ninja is intentionally built on top of Django, not just as a database access layer.
Django provides much more than an ORM:
A mature model system tightly integrated with the framework
Migrations and schema evolution
The admin panel
Signals, authentication, permissions, and a large ecosystem
Adopting a separate schema and code-generation pipeline would mean stepping outside Django’s model system and duplicating responsibilities that Django already handles well.
Lazy Ninja’s goal is not to replace Django’s data layer, but to amplify it — even if that means accepting the limits of static analysis in exchange for runtime flexibility.
Making the trade-offs explicit
Rather than pretending full static guarantees were possible, I chose to make the boundaries explicit.
Strategic type: ignore
user_id = user.id # type: ignore[attr-defined]
Here, Django guarantees that every model instance has a primary key.
The type: ignore is local and intentional.
Safe attribute access with getattr
last_login = getattr(user, "last_login", None)
This allows optional fields without breaking at runtime and without lying to the type system.
Conditional logic with hasattr
if hasattr(user, "is_staff"):
...
This is especially useful for compatibility across Django versions or custom user models.
Encapsulating ambiguity with type guards
from typing import TypeGuard
def is_django_model(obj) -> TypeGuard[Model]:
return hasattr(obj, "_meta")
This keeps dynamic checks localized and improves readability.
The outcome
This approach led to a predictable result:
Zero runtime errors
Some IDE warnings
A codebase that is honest about its constraints
Most importantly, it avoided the illusion of safety that comes from forcing static guarantees where they cannot truly exist.
The main lesson
Static analysis is not a universal solution.
It has real limits when applied to systems designed around runtime introspection and metaprogramming. Lazy Ninja does not fight type checkers — it operates beyond what they can fully model.
The real challenge is being explicit about where static guarantees end and runtime guarantees begin, and designing systems that respect that boundary.
Final reflection
Building Lazy Ninja ended up being less about generating routes and more about understanding the architectural limits of static analysis in highly dynamic systems.
If you’ve built frameworks, ORMs, or tooling that relies heavily on metaprogramming, you’ve likely faced the same trade-offs — regardless of language.
Lazy Ninja is available on GitHub, and you can experiment with it directly in your Django projects.
Top comments (0)