Django ORM · Relaciones

Relaciones One-to-Many

Aprende a modelar relaciones de uno a muchos en Django con ForeignKey: el campo más utilizado en cualquier proyecto.

📚 Nivel: Iniciación 🕐 Lectura ~25 min 🐍 Python + Django Con ejercicios

01.¿Qué es una relación One-to-Many?

Una relación One-to-Many (uno a muchos) describe el caso en que un único registro de una tabla está asociado con múltiples registros de otra tabla, pero cada uno de esos registros del otro lado pertenece a uno solo.

En una base de datos, los tres tipos de relaciones son:

RelaciónDescripciónEjemploDjango
One-to-One Un registro ↔ exactamente uno Persona ↔ Pasaporte OneToOneField
One-to-Many Un registro → muchos del otro lado Autor → Libros ForeignKey
Many-to-Many Muchos ↔ muchos Estudiantes ↔ Cursos ManyToManyField
💡

Las relaciones One-to-Many son, con diferencia, las más comunes en el desarrollo web. Prácticamente cualquier aplicación las usa: posts de un blog, comentarios de un usuario, productos de una categoría, pedidos de un cliente…

En Django, una relación One-to-Many se implementa con el campo ForeignKey, que siempre se coloca en el modelo del lado "muchos".

02.Ejemplos del mundo real

La clave para identificar una relación One-to-Many es responder a: "¿Puede un X tener muchos Y?" y "¿Pertenece cada Y a un único X?". Si ambas son SÍ, es One-to-Many.

📝 Autor
— tiene muchos →
📚 Libros
📂 Categoría
— tiene muchos →
📰 Artículos
👤 Usuario
— tiene muchos →
💬 Comentarios
🏢 Empresa
— tiene muchos →
👷 Empleados
🛒 Pedido
— tiene muchos →
📦 Líneas de pedido
📁 Proyecto
— tiene muchos →
✅ Tareas
📌

Fíjate en la dirección: el FK siempre va en el lado "muchos". Un libro tiene un autor (FK en Libro), no al revés. Piensa en ello como: "¿Quién necesita saber a quién pertenece?"

03.ForeignKey en Django

Vamos a construir un blog sencillo con Autores y Posts. Un autor puede escribir muchos posts, pero cada post tiene un único autor.

Definir los modelos

blog/models.py Python · Django
from django.db import models


class Autor(models.Model):
    nombre = models.CharField(max_length=200)
    email  = models.EmailField(unique=True)
    bio    = models.TextField(blank=True)

    def __str__(self):
        return self.nombre


class Post(models.Model):
    titulo    = models.CharField(max_length=300)
    contenido = models.TextField()
    publicado = models.BooleanField(default=True)
    creado_en = models.DateTimeField(auto_now_add=True)

    # 👇 Aquí está la clave: ForeignKey al Autor
    autor = models.ForeignKey(
        Autor,
        on_delete=models.CASCADE,      # si se borra el autor, se borran sus posts
        related_name='posts',          # autor.posts.all()
        verbose_name='Autor del post'
    )

    class Meta:
        ordering      = ['-creado_en']   # más recientes primero
        verbose_name  = 'Post'
        verbose_name_plural = 'Posts'

    def __str__(self):
        return self.titulo

El campo autor en Post crea automáticamente una columna autor_id en la base de datos (un número entero con la clave primaria del autor). Django gestiona esto de forma transparente.

Parámetros principales de ForeignKey

ParámetroObligatorioDescripción
to (1er arg)✅ SíModelo al que apunta la FK
on_delete✅ SíQué hacer si se borra el padre
related_name❌ NoNombre del manager inverso
null=True❌ NoPermite que el campo sea NULL
blank=True❌ NoPermite campo vacío en formularios
db_index❌ NoCrea índice (True por defecto)
limit_choices_to❌ NoFiltra opciones en formularios/admin
to_field❌ NoCampo del modelo padre al que apunta

04.on_delete: ¿qué pasa cuando se borra el padre?

on_delete es un parámetro obligatorio en todo ForeignKey. Define qué ocurre con los registros hijos cuando se elimina el registro padre. Es una decisión de diseño muy importante.

CASCADE
Borra también todos los hijos. Si eliminas un autor, se borran todos sus posts. El más usado.
PROTECT
Lanza un error ProtectedError e impide el borrado mientras haya hijos. No puedes borrar un autor si tiene posts.
SET_NULL
Pone el campo a NULL en los hijos. Requiere null=True en el campo. El post queda sin autor.
SET_DEFAULT
Asigna el valor por defecto del campo. Requiere default=... en el campo.
DO_NOTHING
No hace nada en Django (puede causar errores de integridad en la base de datos). Úsalo solo si gestionas la integridad a mano.
Ejemplos de on_delete Python
# CASCADE: borrar el padre borra los hijos (el más común)
autor = models.ForeignKey(Autor, on_delete=models.CASCADE)

# PROTECT: protege al padre, lanza error si intentas borrarle
categoria = models.ForeignKey(Categoria, on_delete=models.PROTECT)

# SET_NULL: el hijo queda huérfano (campo = NULL)
revisor = models.ForeignKey(
    Usuario,
    on_delete=models.SET_NULL,
    null=True,
    blank=True
)

# SET_DEFAULT: asigna un valor por defecto
departamento = models.ForeignKey(
    Departamento,
    on_delete=models.SET_DEFAULT,
    default=1   # id del departamento "General"
)
⚠️

Elige on_delete con cuidado según la lógica de negocio. CASCADE es el más práctico para contenido dependiente, pero PROTECT es más seguro cuando no quieres pérdida accidental de datos.

05.Migraciones y estructura de tablas

Terminal Shell
# 1. Generamos la migración
python manage.py makemigrations blog

# 2. La aplicamos
python manage.py migrate

# 3. (Opcional) Ver el SQL que genera Django
python manage.py sqlmigrate blog 0001

A diferencia de Many-to-Many (que crea 3 tablas), ForeignKey solo genera 2 tablas. La relación vive como una columna en la tabla del lado "muchos":

Estructura de tablas en la base de datos
blog_autor
PK id
nombre
email
bio
1
blog_post
PK id
titulo
contenido
publicado
creado_en
FK autor_id
La columna autor_id en blog_post apunta al id de blog_autor
💡

Django nombra la columna FK añadiendo _id al nombre del campo. Si el campo se llama autor, la columna en la BD se llama autor_id. Puedes acceder al ID directamente con post.autor_id sin hacer una consulta a la base de datos.

06.Operaciones básicas con el ORM

Crear objetos con ForeignKey

Django Shell / views.py Python
# ── Crear autores ──
pablo = Autor.objects.create(nombre="Pablo García", email="pablo@blog.es")
lucia = Autor.objects.create(nombre="Lucía Martín", email="lucia@blog.es")

# ── Crear posts vinculados a un autor ──
p1 = Post.objects.create(
    titulo    = "Introducción a Django",
    contenido = "Django es un framework web...",
    autor     = pablo    # ← pasamos el objeto directamente
)

p2 = Post.objects.create(
    titulo    = "ForeignKey explicado",
    contenido = "Un ForeignKey es...",
    autor_id  = pablo.id  # ← o pasamos el ID directamente (más eficiente)
)

p3 = Post.objects.create(titulo="Python tips", contenido="...", autor=lucia)

# ── Acceder al padre desde el hijo ──
print(p1.autor)         # → Pablo García  (hace una query)
print(p1.autor.email)   # → pablo@blog.es
print(p1.autor_id)      # → 1  (NO hace query, es un simple campo)

# ── Cambiar el autor de un post ──
p1.autor = lucia
p1.save()

# ── Borrar un post (no afecta al autor) ──
p2.delete()

# ── Borrar un autor (con CASCADE borra sus posts también) ──
pablo.delete()   # ¡elimina pablo y todos sus posts!

El Manager inverso: de padre a hijos

La parte más potente de ForeignKey es que Django crea automáticamente un Manager inverso en el modelo padre que te permite acceder a todos los hijos desde el padre.

Manager inverso Python
# Con related_name='posts' definido → usamos autor.posts
lucia.posts.all()
# <QuerySet [<Post: Python tips>]>

lucia.posts.count()         # → número de posts
lucia.posts.filter(publicado=True)  # posts publicados de Lucía
lucia.posts.order_by('-creado_en')  # más recientes primero
lucia.posts.exists()         # → True si tiene algún post

# Sin related_name → Django usa el nombre por defecto: modelo_set
lucia.post_set.all()        # funciona igual, pero es menos legible

# También puedes crear hijos directamente desde el padre
lucia.posts.create(
    titulo    = "Nuevo post de Lucía",
    contenido = "Contenido aquí..."
)
# Equivale a Post.objects.create(titulo=..., autor=lucia)

El Manager inverso (autor.posts) soporta exactamente los mismos métodos que Post.objects: .all(), .filter(), .exclude(), .create(), .count(), .exists()… Es igual de potente.

07.Consultas avanzadas con ForeignKey

Con el doble guión bajo __, Django permite navegar por las relaciones para filtrar, anotar y ordenar cruzando tablas sin escribir SQL.

Consultas ORM avanzadas Python
from django.db.models import Count, Avg, Q

# ── 1. Posts de un autor concreto (por nombre) ──
Post.objects.filter(autor__nombre="Lucía Martín")

# ── 2. Posts de autores cuyo email termina en @blog.es ──
Post.objects.filter(autor__email__endswith="@blog.es")

# ── 3. Autores con MÁS de 3 posts publicados ──
Autor.objects\
    .annotate(num_posts=Count('posts'))\
    .filter(num_posts__gt=3)\
    .order_by('-num_posts')

# ── 4. Autores SIN ningún post ──
Autor.objects.filter(posts__isnull=True)

# ── 5. Posts publicados de autores que tienen bio ──
Post.objects.filter(
    publicado=True,
    autor__bio__gt=""   # bio no vacía
)

# ── 6. Posts de Lucía O sin autor asignado (FK nula) ──
Post.objects.filter(
    Q(autor=lucia) | Q(autor__isnull=True)
)

# ── 7. Cada autor con el número de posts que tiene ──
autores = Autor.objects.annotate(
    num_posts=Count('posts')
)
for a in autores:
    print(f"{a.nombre}: {a.num_posts} posts")

# ── 8. Excluir posts del autor con id=5 ──
Post.objects.exclude(autor_id=5)

10.ForeignKey en formularios

Django convierte automáticamente los campos ForeignKey en un <select> (desplegable) en los formularios. Veamos cómo trabajar con ellos.

ModelForm con ForeignKey

blog/forms.py Python · Django Forms
from django import forms
from .models import Post, Autor


class PostForm(forms.ModelForm):
    class Meta:
        model  = Post
        fields = ['titulo', 'contenido', 'autor', 'publicado']
        # Django genera un <select> automáticamente para el campo 'autor'

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Personalizar el queryset del select
        self.fields['autor'].queryset = Autor.objects.order_by('nombre')
        self.fields['autor'].label    = "Selecciona el autor"
        self.fields['autor'].empty_label = "-- Elige un autor --"


# ── En la vista ──
from django.shortcuts import render, redirect

def crear_post(request):
    if request.method == 'POST':
        form = PostForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('lista_posts')
    else:
        form = PostForm()
    return render(request, 'crear_post.html', {'form': form})

11.ForeignKey en el Admin de Django

El Admin de Django reconoce los campos FK y los muestra como desplegables. Con un poco de configuración puedes hacerlo mucho más usable.

blog/admin.py Python · Django Admin
from django.contrib import admin
from .models import Autor, Post


class PostInline(admin.TabularInline):
    """Muestra los posts directamente dentro del Autor."""
    model               = Post
    extra               = 1
    fields              = ['titulo', 'publicado', 'creado_en']
    readonly_fields     = ['creado_en']
    show_change_link    = True


@admin.register(Autor)
class AutorAdmin(admin.ModelAdmin):
    list_display  = ['nombre', 'email', 'num_posts']
    search_fields = ['nombre', 'email']
    inlines       = [PostInline]   # los posts aparecen dentro del autor

    @admin.display(description='Nº Posts', ordering='posts__count')
    def num_posts(self, obj):
        return obj.posts.count()


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display    = ['titulo', 'autor', 'publicado', 'creado_en']
    list_filter     = ['publicado', 'autor']
    search_fields   = ['titulo', 'autor__nombre']
    raw_id_fields   = ['autor']   # útil si hay miles de autores
    list_select_related = ['autor']  # select_related automático en el listado

raw_id_fields es muy útil cuando la tabla relacionada tiene muchos registros. En vez de cargar un <select> enorme, muestra un campo de texto con un botón de búsqueda.

12.Errores comunes y cómo evitarlos

!

Olvidar on_delete

Es obligatorio desde Django 2.0. Si lo omites, obtienes un TypeError. Siempre especifícalo explícitamente.

!

El problema N+1 en bucles

Acceder a post.autor dentro de un bucle sin select_related lanza una query por cada iteración. Usa siempre .select_related('autor') al consultar.

!

Colisión de related_name

Si tienes dos FK al mismo modelo sin related_name, Django lanzará un error de validación. Siempre define related_name único para cada FK.

!

Referenciar el modelo antes de definirlo

Si el modelo al que apunta la FK está definido después en el mismo fichero, usa el nombre como string: ForeignKey('MiModelo', ...) en vez del objeto de clase directamente.

!

FK nullable sin null=True

Si quieres que un campo FK pueda estar vacío (opcional), debes añadir null=True, blank=True. Sin null=True no puede guardarse como NULL en la BD.

Referencia circular (string forward reference) Python
# ❌ Error si Categoria se define DESPUÉS de Post
class Post(models.Model):
    categoria = models.ForeignKey(Categoria, ...)  # NameError!


# ✅ Correcto: usar string con el nombre del modelo
class Post(models.Model):
    categoria = models.ForeignKey('Categoria', on_delete=models.CASCADE)

# Si el modelo está en otra app, incluye el nombre de la app
class Comentario(models.Model):
    usuario = models.ForeignKey('auth.User', on_delete=models.CASCADE)

13.Buenas prácticas

1

Define siempre related_name

Aunque sea opcional, un buen related_name hace tu código mucho más legible. Usa el nombre en plural del modelo hijo: related_name='posts', related_name='comentarios'.

2

Usa select_related en tus vistas de listado

Cualquier vista que muestre una lista de objetos y acceda a sus FK debe usar select_related. Tu base de datos te lo agradecerá.

3

Elige on_delete según la lógica de tu aplicación

No uses CASCADE por defecto sin pensar. En algunos casos PROTECT o SET_NULL son más apropiados y evitan pérdidas de datos accidentales.

4

Accede a autor_id en vez de autor.id

Si solo necesitas el ID del padre (para comparar o guardar), usa post.autor_id directamente. Evitas una consulta a la base de datos innecesaria.

5

Usa strings para referencias hacia adelante o a otras apps

Cuando el modelo al que apuntas está en otra app o se define después, usa siempre el formato string: 'app_name.ModelName' o 'ModelName'.

6

Añade db_index=False solo si tienes razón

Por defecto, Django crea un índice en cada FK (para acelerar JOINs). Solo desactívalo (db_index=False) si tienes una razón muy específica de rendimiento.


14.Mini Quiz de Comprobación

Pon a prueba lo que has aprendido con estas preguntas:

🧠 Test Rápido — One-to-Many

1. En una relación One-to-Many entre Autor y Post, ¿en cuál de los dos modelos se coloca el ForeignKey?

2. ¿Cuántas tablas crea Django cuando defines un ForeignKey entre dos modelos?

3. Si defines on_delete=models.CASCADE y borras un Autor, ¿qué ocurre con sus Posts?

4. ¿Cuál es la forma más eficiente de acceder al nombre del autor de cada post en un bucle?

5. Si tienes related_name='posts' en el FK, ¿cómo obtienes todos los posts de un autor?

15.Resumen final

Has completado la guía de relaciones One-to-Many en Django. Aquí tienes un resumen de los conceptos clave:

🔑

ForeignKey

El campo que define la relación. Siempre va en el modelo del lado "muchos". Crea una columna campo_id en la BD.

💥

on_delete

Parámetro obligatorio. Define qué pasa con los hijos cuando se borra el padre: CASCADE, PROTECT, SET_NULL…

↩️

related_name

Nombre del manager inverso. Permite navegar desde el padre a sus hijos con autor.posts.all().

select_related

Optimización esencial: hace un JOIN y evita el problema N+1 al acceder a FK en bucles.

🔢

campo_id

Django crea autor_id automáticamente. Acceder a él directamente es más rápido que acceder a autor.id.

📋

Manager inverso

Desde el padre puedes usar .all(), .filter(), .count(), .create()… como con cualquier Manager.

🚀

¿Cuál es el siguiente paso? Practica creando tu propia app con ForeignKey: un sistema de comentarios para un blog, un gestor de tareas por proyecto, o un catálogo de productos por categoría. ¡El ORM de Django se vuelve muy potente cuando lo usas con soltura!