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ón | Descripción | Ejemplo | Django |
|---|---|---|---|
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.
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
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ámetro | Obligatorio | Descripción |
|---|---|---|
to (1er arg) | ✅ Sí | Modelo al que apunta la FK |
on_delete | ✅ Sí | Qué hacer si se borra el padre |
related_name | ❌ No | Nombre del manager inverso |
null=True | ❌ No | Permite que el campo sea NULL |
blank=True | ❌ No | Permite campo vacío en formularios |
db_index | ❌ No | Crea índice (True por defecto) |
limit_choices_to | ❌ No | Filtra opciones en formularios/admin |
to_field | ❌ No | Campo 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.
CASCADEPROTECTProtectedError e impide el borrado mientras haya hijos. No puedes borrar un autor si tiene posts.SET_NULLNULL en los hijos. Requiere null=True en el campo. El post queda sin autor.SET_DEFAULTdefault=... en el campo.DO_NOTHING# 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
# 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":
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
# ── 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.
# 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.
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
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.
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.
# ❌ 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
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'.
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á.
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.
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.
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'.
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:
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!