| published by | Tim Schilling |
|---|---|
| in blog | Better Simple |
| published date | 2026-07-15 |
| original entry | Nifty Django Feature: Model._meta.get_fields() |
A nifty feature in Django is the get_fields() method provided by the Model._meta API. This method allows you to access all the fields on a model class or instance. The primary use case for this is when you’re doing a bit of metaprogramming. In my case, it was determining which related model instances need to be deleted when deleting a given model instance. But I’m getting ahead of myself.
Let’s print out Species._meta.get_fields() for the following models.
class Species(models.Model):
name = models.CharField()
class Pet(models.Model):
species = models.ForeignKey(Species, related_name="pet_set", on_delete=models.CASCADE)
class Vet(models.Model):
specialty = models.ForeignKey(Species, related_name="vet_set", on_delete=models.CASCADE)
print(Species._meta.get_fields())
# (AutoField: my_app.species.id,
# CharField: my_app.species.name,
# <ManyToOneRel: my_app.pet>,
# <ManyToOneRel: my_app.vet>)
The AutoField: my_app.species.id may be confusing, especially if you’re new to Django. This is the field that’s added automatically to every Django model. The CharField: my_app.species.name is straightforward, it’s the name defined directly on Species.
The two ManyToOneRel may be confusing though. These are the fields that allow us to access the related pets and vets from a Species instance. These ManyToOneRel are the fields that power our ability to access related pets via species_instance.pet_set.all().
As I mentioned, one way to use the get_fields() method is when implementing your own bulk deletion logic. If you have a lot of data, it’s possible that deleting one object will cause a cascade of tens of thousands of other deletions. Deleting data efficiently is one of those problems that you only need to worry about once you reach a certain scale. When you do reach that scale, you’ll find that Django’s default delete logic either takes too long gathering the objects to delete, or the delete queries run too long and lock your tables too aggressively.
A key part of any implementation will be determining which models need to be deleted to delete a given model instance. For example,
class Species(models.Model):
name = models.CharField()
class Pet(models.Model):
species = models.ForeignKey(Species, on_delete=models.CASCADE)
humans = models.ManyToManyField(User, blank=True)
class Nickname(models.Model):
pet = models.ForeignKey(Pet, on_delete=models.CASCADE)
class Vet(models.Model):
specialty = models.ForeignKey(Species, on_delete=models.CASCADE)
class Appointment(models.Model):
pet = models.ForeignKey(Pet, on_delete=models.CASCADE)
vet = models.ForeignKey(Vet, on_delete=models.CASCADE)
If we’re deleting a Species instance, first we must delete the Pet, Vet and Appointment instances that relate to it. But before we can delete a Pet instance, we must delete the related Nickname and Appointment instances. And before we can delete a Vet, we must delete the related Appointment instances. It’s possible to work this out manually and have a static delete method like:
def delete_species(species):
Appointment.objects.filter(pet__species=species).delete()
Vet.objects.filter(specialty=species).delete()
Nickname.objects.filter(pet__species=species).delete()
Pet.objects.filter(species=species).delete()
Species.objects.filter(id=species.id).delete()
But this is fragile. And I think it has a bug around Pet.humans. Either way, any change to our model relationships would cause this to change. Plus we should be able to determine this programatically.
Below is the code that you can use that will return the fields that reference a given model. This is primarily only useful if you’re looking for these specific relationships. The comments attempt to explain the code per line.
def fields_referencing_model(model):
"""
Find the all fields that reference the given model.
For Species, return fields from the following Foreign Keys
- Pet.species
- Pet_human.pet (through model)
- Appointment.pet
- Vet.species
- Appointment.vet
"""
# include_hidden=True will include the relationships on
# ManyToManyField(through=) models.
for field in model._meta.get_fields(include_hidden=True):
# Only do something with relationship fields
# (ForeignKey and OneToOneField)
if field.one_to_many or field.one_to_one:
if not hasattr(field, "field"):
# Skip ForeignKey/OneToOneField on this model,
# we only want inbound relationships
continue
yield field
# Recurse to support traveling the whole model graph
yield from fields_referencing_model(field.related_model)
If you’re wondering why if field.one_to_many or field.one_to_one doesn’t account for ManyToManyField it’s because a ManyToManyField breaks down into a new model with a two ForeignKey fields, one to each side of the relationship. That is called the through model and the fields for the relationship to the through model is what we want to capture.
Now that we have all the relationship fields, we need to determine the deletion order. You could do some DAG analysis to calculate this, or you can use the Python standard library’s TopologicalSorter class. I vote we pick the thing we don’t have to build ourselves.
While Python’s documentation provides an example of a task processor, it works equally well when sorting model relationships.
from graphlib import TopologicalSorter
def get_deletion_order(model):
"""Return all models that need to be deleted, before this model."""
ts = TopologicalSorter()
# Add the root.
ts.add(model)
for field in fields_referencing_model(model):
# add(node, *predecessors)
# Example: (Species, Vet)
ts.add(field.model, field.related_model)
# static_order() returns the nodes that had no predecessors,
# working backwards until it reaches back to the root.
return list(ts.static_order())
for i, m in enumerate(get_deletion_order(Species), 1):
print(f" {i}. {m.__name__}")
# 1. Pet_humans
# 2. Appointment
# 3. Nickname
# 4. Vet
# 5. Pet
# 6. Species
While this gives us the deletion order, it falls short of providing us with the way to delete the related data. We’re missing the filtering aspect of Appointment.objects.filter(pet__species=species).delete().
This section will be code heavy and assumes we’re using Celery for background task processing. While we do have the order we need to delete the models in, we need to determine the lookup path between the root model to any of the other models so that we only delete the related data.
The following code will include a function to trigger a delete (schedule_delete), a task that deletes data for a given model that’s related to the original model instance (delete_task) and a way to determine the ORM filter lookup path between the original model instance and the model being deleted (lookups_to_root).
from django.apps import apps
from django.db import transaction
from celery import shared_task, chain
def schedule_delete(model_instance):
"""Schedule the deletion tasks to delete a given model incrementally."""
root = model_instance._meta.model
lookup_map = lookups_to_root(root)
steps = [
delete_task.si(
model_label=model._meta.label,
lookups=["pk"] if model is root else lookup_map[model],
lookup_pk=model_instance.pk,
)
for model in get_deletion_order(root)
]
return chain(*steps).apply_async()
@shared_task
def delete_task(model_label: str, lookups: list[str], lookup_pk: int):
"""
Delete model instances that are related to the given lookups.
"""
model = apps.get_model(model_label)
with transaction.atomic():
for lookup in lookups:
model.objects.filter(**{lookup: lookup_pk}).delete()
def lookups_to_root(root):
"""Create list of paths from related models back to the root.
Map each model to the relationship lookup(s) that walk back to a root
instance, e.g.:
Pet -> ['species']
Nickname -> ['pet__species']
Appointment -> ['pet__species', 'vet__specialty']
"""
children = {}
# Build a complete map of the relationships between models.
for field in fields_referencing_model(root):
children.setdefault(field.model, set()).add(
(field.related_model, field.field.name)
)
paths = {}
def _walk(model, lookup_so_far):
for child, fk_name in children.get(model, ()):
if lookup_so_far is None:
child_lookup = fk_name
else:
child_lookup = f"{fk_name}__{lookup_so_far}"
paths.setdefault(child, []).append(child_lookup)
_walk(child, child_lookup)
# Use the relationship map to generate the paths between
# the root model and the related models.
_walk(root, None)
return paths
This is a fairly crude implementation because it will delete all the related instances for a particular model in a single query. Most likely if you need to reach for this, you should also delete data in batches, but that’s beyond the scope of this post.
The get_fields() method opens the door to some interesting opportunities to build dynamic functionality in our projects. I hope you found it nifty too!
If you have thoughts, comments, or questions, please let me know. You can set up a meeting with me, or find me on the Fediverse, Django Discord server or use email.