We’ve all been there. You’re shipping a feature, your manual tests look "okay-ish," and you push to staging. Then, the bug reports start trickling in: "I can't sign up. It just says 'Something went wrong'."
Recently, I watched a developer wrestle with a registration bug that seemed to appear out of nowhere. It wasn't a complex logic error—it was a tooling error. This is the story of how a formatting mishap, a misunderstood linter rule, and a Django TypeError created the perfect storm.
The Mystery: An "Abstract" Error
The team was seeing an Internal Server Error (500) on the registration endpoint. The frontend just showed a generic "Server Error," and the logs were screaming:
TypeError: CustomUser() got unexpected keyword arguments: 'password2'
At first glance, the code looked fine. But under the hood, a series of "helpful" tools had created a mess.
The Motivation: How it Broke Silently
In this project, we used a ModelSerializer for registration. We needed a password2 field for the "Confirm Password" check. During a cleanup or a formatting run, the line that removed password2 from the data dictionary was accidentally shifted or commented out.
When the create method ran, it looked like this:
def create(self, validated_data):
# We extracted the main password
password = validated_data.pop("password")
# BUT 'password2' was accidentally left in the dictionary!
# validated_data = {"email": "user@example.com", "password2": "secret123"}
user = User.objects.create_user(**validated_data)
user.set_password(password)
user.save()
return user
Because **validated_data was passed directly into create_user, Django tried to pass password2 as a keyword argument to the User model. Since the model doesn't have a password2 field, the whole thing crashed. Because the error happens during the database save/creation phase, the UI often just returns a vague "Internal Server Error," leaving the user stranded.
The Pre-Commit Struggle: Ruff vs. Reality
To prevent these "dirty" dictionaries, we introduced Ruff via pre-commit hooks. We wanted to catch unused variables before they hit the repo.
The developer tried to fix the TypeError by popping the variable:
password2 = validated_data.pop("password2")
Suddenly, Ruff failed the commit with:
F841 Local variable 'password2' is assigned to but never used
The "Wrong" Fix
The developer’s first instinct was to tell the linter to be quiet. They modified the config to make F841 "unfixable," thinking it would ignore it:
- id: ruff
args: ["--fix", "--unfixable=F841"]
The result? The pre-commit hook still failed.
In the linter world, --unfixable just means "don't delete this for me automatically." It doesn't mean "ignore the error." The build stayed broken, and the friction increased.
Senior Wisdom: Common Python/Django Friction Points
If you are a Python developer, you will run into these flags. Don't fight the tool; understand the syntax.
1. Handling Unused Variables (F841)
If you need to remove something from a dictionary but don't need the value, don't assign it to a name.
-
The "Dirty" Fix:
password2 = validated_data.pop("password2")(Triggers F841) -
The "Clean" Fix:
validated_data.pop("password2", None)(No variable assigned = no linter error) -
The Pythonic Fix:
_password2 = validated_data.pop("password2")(The underscore prefix signals intentionality to the linter).
2. The Django User Trap (DJ001/DJ008)
Ruff will flag you for importing the User model directly or using settings.AUTH_USER_MODEL where a string or get_user_model() is expected.
-
The Fix: Always use
get_user_model()for logic and string references (e.g.,'myapp.CustomUser') for ForeignKeys to avoid circular imports and hardcoding.
Pro-Tip: A Robust ruff.toml for Django Teams
Instead of fighting the linter, configure it to be your partner. Here is a battle-tested configuration that balances strictness with the reality of Django development.
# ruff.toml
target-version = "py312"
line-length = 88
[lint]
# Enable a wide range of rules
select = [
"F", # Pyflakes (finds bugs like unused variables)
"E", # Error (standard style)
"W", # Warning
"I", # Isort (keeps imports clean)
"DJ", # Django-specific rules
"B", # Bugbear (finds common design flaws)
"SIM", # Simplicity (helps you write cleaner code)
]
# Specifically ignore rules that are too noisy for your team
ignore = [
"DJ001", # Sometimes direct User imports are necessary in specific managers
]
[lint.per-file-ignores]
# Ignore unused imports in __init__.py
"__init__.py" = ["F401"]
# Ignore long lines and specific bugs in auto-generated migrations
"**/migrations/*" = ["E501", "F401"]
[format]
quote-style = "double"
indent-style = "space"
The Takeaway
The TypeError in our registration wasn't just a typo; it was a symptom of ignoring the linter's warnings about unused data.
- Linters are guardrails, not hurdles. When Ruff flags an unused variable, it's often telling you that you're carrying "garbage" data that might break a downstream function.
-
Explicit is better than implicit. Popping data out of
validated_dataexplicitly ensures your model managers stay clean. -
Don't silcence; Solve. Using
# noqaor--unfixableto hide an error is like putting a sticker over the "Check Engine" light.
By moving from password2 = data.pop() to a simple data.pop("password2", None), we satisfied the linter, fixed the TypeError, and saved the user experience.
Keep your linting strict and your functions pure. Your future self (and your production logs) will thank you.
Top comments (0)