RizTech Academy logo
RizTech Academy
Models and the ORMLesson 3 of 635 min

ForeignKey, ManyToMany and OneToOne

Real data is connected. A patient has appointments; an appointment is with a doctor; a diagnostic report belongs to a patient. Modelling those connections correctly is most of what makes a data model good, and Django gives you three relationship fields — ForeignKey, ManyToManyField, OneToOneField — that map directly onto the three ways things relate. This lesson is how to choose between them, and how to use the links once they exist.

The three relationships, and how to tell them apart

Ask "how many of each side relate to how many of the other?" and the answer picks the field:

  • One-to-many → ForeignKey. One doctor has many appointments; each appointment has one doctor. The ForeignKey goes on the "many" side (the appointment). This is by far the most common relationship.
  • Many-to-many → ManyToManyField. A patient can have many diagnostic tests, and each test type is taken by many patients. Neither side "owns" one of the other.
  • One-to-one → OneToOneField. Exactly one of each: a User has one PatientProfile, and that profile belongs to one user. Rare, but exactly right when you are extending one row with another.

Getting this choice right is the crux. A ForeignKey where you needed many-to-many forces awkward workarounds later; the reverse over-complicates a simple link. Model the real cardinality.

ForeignKey: the one-to-many workhorse

In Nidaan, an appointment links to one patient and one doctor:

class Appointment(models.Model):
    patient = models.ForeignKey(Patient, on_delete=models.CASCADE, related_name="appointments")
    doctor = models.ForeignKey(Doctor, on_delete=models.PROTECT, related_name="appointments")
    scheduled_for = models.DateTimeField()

The ForeignKey lives on Appointment (the many side). Two arguments carry real meaning, and beginners skate past both:

on_delete — what happens to this row when the thing it points to is deleted. You must specify it, and the choice is a genuine domain decision:

  • CASCADE — delete this row too. Deleting a patient deletes their appointments — verified: a patient with one appointment removed drops the appointment count from 5 to 4. Right when the child has no meaning without the parent.
  • PROTECT — refuse the deletion, raising ProtectedError. Deleting a doctor who has appointments is blocked — verified, it raises rather than silently orphaning or destroying appointment history. Right when you must not lose the related data by accident.
  • SET_NULL (needs null=True) — keep the row, set the link to NULL. Right when the row outlives its parent (an appointment whose doctor left, kept for records).

Choosing CASCADE versus PROTECT versus SET_NULL is you deciding what should happen to real data — a clinic almost certainly wants PROTECT on a doctor (never quietly delete medical history) and might want CASCADE or SET_NULL elsewhere. It is not boilerplate; it is policy.

related_name — the name of the reverse accessor (below). Set it deliberately; it makes your reverse queries readable.

Once the ForeignKey exists, you traverse it in both directions with attribute access — no SQL:

# forward: from the appointment to what it points to
appointment.patient.name        # "Patient0"
appointment.doctor.name         # "Rao"

# reverse: from the pointed-to object back to the many side, via related_name
patient.appointments.all()      # all appointments for this patient
patient.appointments.count()    # verified: 1
doctor.appointments.count()     # verified: 3  (Dr Rao has 3 appointments)

Forward access (appointment.patient) gives you the single related object. Reverse access (patient.appointments) gives you a manager over the many related objects — and it is named by related_name. Without related_name, Django defaults to appointment_set, which works but reads poorly; setting related_name="appointments" lets you write patient.appointments, which reads like English. The reverse accessor is a full queryset, so you can filter it: patient.appointments.filter(status="booked").

Note both ForeignKeys above use related_name="appointments" — that is fine because they are on different target models (Patient and Doctor), so patient.appointments and doctor.appointments are unambiguous.

When each side relates to many of the other, use ManyToManyField. Suppose a diagnostic TestType (blood sugar, X-ray) can be ordered for many patients, and a patient can have many test types:

class TestType(models.Model):
    name = models.CharField(max_length=100)

class Patient(models.Model):
    # ...
    tests = models.ManyToManyField(TestType, related_name="patients", blank=True)

Behind the scenes Django creates a hidden join table holding pairs of ids — you never write it, but it is worth knowing it exists (it is where a many-to-many actually lives). You use it as a collection:

patient.tests.add(blood_sugar)          # link them
patient.tests.remove(blood_sugar)       # unlink
patient.tests.all()                     # all test types for this patient
blood_sugar.patients.all()              # reverse: all patients with this test (related_name)

When the relationship itself needs data — when a test was ordered, by which doctor — you add a through model (an explicit join model with its own fields). That is a step beyond this lesson, but know the escape hatch exists: a plain ManyToManyField for a bare link, a through model when the link carries information.

OneToOneField: extending a row

OneToOneField is a ForeignKey constrained to one-per-side. Its classic use is extending a model you do not want to modify — most often Django's User:

class PatientProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
    phone = models.CharField(max_length=15)

Now user.profile (singular — one object, not a set) gives the profile, and profile.user gives the user. This is the sanctioned way to attach extra fields to User without a custom user model — though, as the auth module will discuss, customising the user model early is often better. Use OneToOneField when "exactly one of each" is the truth; using a ForeignKey there would wrongly permit many.

The rule of thumb

Count the cardinality, and let it choose the field. One-to-many is ForeignKey (on the many side); many-to-many is ManyToManyField; one-to-one is OneToOneField. Then set on_delete as a real decision about what should happen to data, and related_name so the reverse side reads well. Get these three right across your models and the ORM lets you walk your data as naturally as following attributes — which is exactly what the next lessons on querysets build on.

Check your work

How to choose the relationship. By cardinality: one-to-many → ForeignKey (on the many side); many-to-many → ManyToManyField; one-to-one → OneToOneField.

What on_delete decides. What happens to this row when its target is deleted — CASCADE (delete too), PROTECT (block, raising ProtectedError), SET_NULL (keep, null the link). A real data-policy choice.

Forward versus reverse access. Forward (appointment.patient) gives the one related object; reverse (patient.appointments, named by related_name) gives a filterable manager over the many.

Why set related_name. It names the reverse accessor; without it you get appointment_set — related_name makes reverse queries read well.

What a ManyToManyField creates, and through. A hidden join table of id pairs, used as a collection (.add/.remove/.all); add a through model when the link itself needs fields.

When OneToOneField. When exactly one of each is the truth — classically extending User with a profile, accessed as a single object (user.profile).

Practice

  1. Add the doctor and patient ForeignKeys to Appointment with related_name="appointments". In the shell, create data and traverse both forward (a.doctor) and reverse (doctor.appointments.all()).
  2. Set the doctor ForeignKey to on_delete=models.PROTECT, give the doctor an appointment, and try to delete the doctor. Confirm you get ProtectedError.
  3. Set the patient ForeignKey to CASCADE, delete a patient with appointments, and confirm the appointments vanish. Then reason about why a clinic wants PROTECT on the doctor but might accept CASCADE on the patient.
  4. Add a TestType model and a ManyToManyField from Patient. Link and unlink with .add/.remove, and query both directions.
  5. Filter a reverse accessor: patient.appointments.filter(status="booked"). Confirm it is a full queryset.
  6. Add a PatientProfile with a OneToOneField to User; access user.profile and note it is one object, not a set.

Official documentation

Next: QuerySets and filtering.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship