# Do string model references hide dependencies in Django?

> Rule AR-32: Import models directly when no import cycle forces a string. When a string is needed, use the absolute form \"app_label.ModelName\" and say why.

Django lets a relationship field name its target model as a string, and the docs recommend it in several places. A search for the class `Manufacturer` doesn't list a field that says `"thirdpartyapp.Manufacturer"`, and a rename tool doesn't change it. Use a real import where the import graph allows it. Use the absolute string where it doesn't, and let Django's checks guard it.

## Why it happens
Lazy references are a strong feature of the ORM. The model field reference describes them: "Lazy relationships allow referencing models by their names (as strings) or creating recursive relationships." A recursive reference uses `"self"`. An absolute reference uses "its `app_label` and class name, allowing for model references across different applications", and the docs add that it "can also help resolve circular imports." A relative reference names a model by its bare name when it "has not been defined yet"; the docs' example resolves `"Manufacturer"` in `Car` to `production.Manufacturer` because `Car` lives in `production/models.py`, and relative references on abstract models are "not relative to the abstract model's `app_label`" but resolved in the concrete subclass. The docs never use the phrase "same app". https://docs.djangoproject.com/en/stable/ref/models/fields/

So the string form exists for good reasons: two apps whose models point at each other can't both import the other at module level. The user model is the standard case. The auth docs say "If you reference `User` directly (for example, by referring to it in a foreign key), your code will not work in projects where the `AUTH_USER_MODEL` setting has been changed", and prescribe `settings.AUTH_USER_MODEL` for foreign keys and signal senders and `get_user_model()` for runtime access.

The cost for an agent is visibility. A foreign key given as a string has no import line, so the dependency between the two apps shows in no import statement. A tool that builds an app dependency graph from imports misses the edge. An agent that deletes or moves the target model finds the string only if it searches for text that matches the label. Django does catch a wrong label, but when the registry loads (see the checks below), not at the moment of the edit.

## Bad and good
An `invoices` app points at `customers.Customer`, and `customers` has no reason to import `invoices`.

```python
# bad: invoices/models.py (string where an import works)
from django.db import models


class Invoice(models.Model):
    customer = models.ForeignKey("customers.Customer", on_delete=models.PROTECT)
    parent = models.ForeignKey("Invoice", null=True, on_delete=models.SET_NULL)
```

```python
# good: invoices/models.py
from django.db import models

from customers.models import Customer


class Invoice(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.PROTECT)
    parent = models.ForeignKey("self", null=True, on_delete=models.SET_NULL)
```

The import line makes the dependency visible to every tool. `customers` is a dependency of `invoices`, a rename of `Customer` updates the field, and the type of `invoice.customer` is known. For a model that points at itself, `"self"` is the documented form.

When the two apps do import each other, keep the string, in absolute form, and say why:

```python
# good: customers/models.py (cycle: invoices.models imports customers.models)
class Customer(models.Model):
    # String because invoices.models imports this module.
    preferred_invoice = models.ForeignKey(
        "invoices.Invoice", null=True, on_delete=models.SET_NULL
    )
```

For the user model, follow the auth docs:

```python
from django.conf import settings
from django.db import models


class Article(models.Model):
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
```

For type hints that need the class inside a cycle, an import under `if TYPE_CHECKING:` keeps the name visible to the checker without importing it at runtime. The typing docs: a module "expensive to import, and which only contain types used for typing annotations, can be safely imported inside an `if TYPE_CHECKING:` block", and `TYPE_CHECKING` is "False at runtime" (https://docs.python.org/3/library/typing.html).

## Trade-offs and when strings are right
- **Cycles.** If a direct import fails with an import error at startup, the string is the fix Django documents. Prefer it over restructuring a working schema under time pressure.
- **Swappable models.** `settings.AUTH_USER_MODEL` is a string by design. Don't replace it with an import of `User`.
- **Reusable apps.** A third-party app can't import a project's model. A string setting is the only option.
- **Direct imports couple load order.** Within one project whose graph has no cycle, that is a cheap price for visibility.
- **Migrations store labels anyway.** Treat migration files as generated output, not as hand-edited references (AR-23 covers generated code).

## Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| Django system check | `fields.E300` (run `python manage.py check`) | "Field defines a relation with model `<model>`, which is either not installed, or is abstract." | 2026-10-30 |
| Django system check | `fields.E307` | a field with a lazy reference to an app or model that isn't installed | 2026-10-30 |
| Django system check | `fields.E301` | a relation to a model "which has been swapped out" | 2026-10-30 |
| Django system check | `signals.E001` | the same wrong-label problem on a signal's string sender | 2026-10-30 |
| Django system check | `models.E022` | a function with a lazy reference to a model that isn't installed | 2026-10-30 |
| ruff | none found | the DJ rules in the rules index (`DJ001`, `DJ003`, `DJ006`, `DJ007`, `DJ008`, `DJ012`, `DJ013`) don't concern model references | 2026-10-30 |

The checks confirm the label resolves. They don't judge the choice between a string and an import. That choice is a review rule.

## Rule block for AGENTS.md / CLAUDE.md
```markdown
## Django model references
- Use a direct import for foreign key, one-to-one and many-to-many targets unless an import cycle forces a string.
- When a string is needed, write "app_label.ModelName" and add a comment naming the cycle. Use "self" for self references.
- Point at the user model with settings.AUTH_USER_MODEL in fields and signal senders, and get_user_model() at runtime. Never import User directly.
- Before renaming or moving a model, search for its label as text ("app_label.ModelName") as well as its class name, then run python manage.py check.
```

## Edge cases
- **Relative strings.** A bare `"Customer"` resolves inside the current app. It reads shorter and hides the app. Prefer the absolute form. Per the docs' example, a bare name resolves in the app that defines the referencing model (https://docs.djangoproject.com/en/stable/ref/models/fields/).
- **Abstract models.** The docs say a recursive reference in an abstract model resolves so each concrete subclass references itself, while "absolute references always point to the same model". Don't use `"self"` in an abstract base when you mean one fixed model.
- **`get_model()`.** The applications reference allows `get_model()` inside `ready()`. It takes a string, so it has the same visibility issue. Use it only where the import would be circular.
- **`get_user_model()` at import time.** The auth docs say it can be called while Django imports models, and that `settings.AUTH_USER_MODEL` is the easier choice in import-time code. If you test with several user models, don't cache its result in a module-level variable; the docs mention the `setting_changed` signal for that case.
- **`swappable=False` on a foreign key.** The docs: "If in doubt, leave it to its default of `True`."
- **String targets in dispatch tables** (`"orders.handlers.on_paid"`) are a different problem. See AR-07.
- **Thin-page note.** This rule overlaps AR-31 on string sender labels and AR-02 on hidden import edges. It stands on the field-level examples and the checks.

## References
Django (fetched 2026-10-30 from the stable docs):
- Model field reference, lazy relationships, recursive and absolute references, `swappable`: https://docs.djangoproject.com/en/stable/ref/models/fields/
- Customizing authentication, "Referencing the User model": https://docs.djangoproject.com/en/stable/topics/auth/customizing/
- System check framework (`fields.E300`, `fields.E301`, `fields.E307`, `signals.E001`, `models.E022`): https://docs.djangoproject.com/en/stable/ref/checks/
- Applications reference (`get_model()`, string sender labels): https://docs.djangoproject.com/en/stable/ref/applications/

Ruff DJ rules: https://docs.astral.sh/ruff/rules/

## Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved

## Related

- [AR-02: Do barrel files hide calls from coding agents?](https://beta.llmgraph.in/agent-readable/ar-02.md)
- [AR-07: Why does my coding agent miss calls made through getattr?](https://beta.llmgraph.in/agent-readable/ar-07.md)
- [AR-31: Why does my coding agent miss Django signal receivers?](https://beta.llmgraph.in/agent-readable/ar-31.md)
- [CH-01: Why does my Django signal break after a refactor?](https://beta.llmgraph.in/agent-readable/ch-01.md)

Last verified 2026-10-30
