# Cómo evitar dobles reservas con una restricción de exclusión en PostgreSQL | Alher Tech

> Por qué comprobar la disponibilidad en el código no basta, cómo una restricción EXCLUDE con tstzrange impide las reservas solapadas y el diseño del sistema de reservas que usamos.

- Canonical page: https://alhertech.com/es/blog/como-evitar-dobles-reservas/
- Site: Alher Tech (custom software, AI agents and SEO engineering, https://alhertech.com/)
- Contact: https://alhertech.com/es/contacto/

---

Software · 25 oct 2026 · 7 min de lectura

Dos personas abren la misma página de reservas, eligen el mismo martes a las 10:00 y pulsan Reservar con unos milisegundos de diferencia. El código comprueba si el hueco está libre, lo encuentra libre las dos veces y guarda las dos. Es el fallo más habitual de los sistemas de reservas y nunca sale en una demo, porque una demo tiene un solo usuario. En el sistema de reservas que usamos nosotros, el que hay detrás de nuestra propia página para agendar llamadas, no puede pasar: no porque el código compruebe con cuidado, sino porque la base de datos rechaza la segunda reserva.

## Por qué comprobar antes no basta

La versión obvia lee las reservas, decide que el hueco está libre e inserta la nueva. Cada paso es correcto. El problema es el hueco entre ellos: dos peticiones pueden leer antes de que ninguna escriba, y las dos ven el hueco libre.

| Momento | Petición A | Petición B |
| --- | --- | --- |
| 1 | Lee las reservas del martes: las 10:00 están libres |  |
| 2 |  | Lee las reservas del martes: las 10:00 están libres |
| 3 | Inserta su reserva a las 10:00 |  |
| 4 |  | Inserta su reserva a las 10:00 |
| Resultado | Confirmada | Confirmada también: dos personas, un hueco |

Hacer la comprobación más rápida, añadir una caché o pasarla al navegador solo estrecha la ventana. Con carga, o con un cliente que reintenta, una ventana pequeña se sigue abriendo. La solución es dejar de pedirle a la aplicación que garantice algo que no puede ver: lo que se está escribiendo en ese mismo instante.

## La regla, escrita en la base de datos

Dos visitantes piden a la API en Rust el mismo hueco de las 10:00; los dos INSERT llegan a PostgreSQL, donde la restricción de exclusión acepta el primero (201, reservada) y rechaza el segundo con el error 23P01, que la API devuelve como 409 SLOT_TAKEN.
Dos peticiones, un hueco: la base de datos hace de árbitro.
PostgreSQL puede imponer por sí solo que «no haya dos reservas confirmadas que se solapen» con una restricción de exclusión. Una restricción única dice que no puede haber dos filas iguales; una de exclusión lo generaliza a cualquier operador, aquí && (se solapan) entre dos rangos de tiempo. Esta es la tabla, sacada de nuestra migración:

create extension if not exists btree_gist; create table bookings ( id uuid primary key default gen_random_uuid(), starts_at timestamptz not null, ends_at timestamptz not null, status text not null default 'confirmed' check (status in ('confirmed', 'cancelled')), -- ... constraint bookings_ends_after_start check (ends_at > starts_at), constraint bookings_no_overlap exclude using gist (tstzrange(starts_at, ends_at) with &&) where (status = 'confirmed') ); - tstzrange(starts_at, ends_at) es la reserva como un rango de instantes. Sus límites por defecto son [), cerrado al empezar y abierto al acabar, así que una reserva de 10:00 a 10:30 y la siguiente de 10:30 a 11:00 no se solapan y caben las dos. - with && significa que dos filas chocan cuando sus rangos se solapan. PostgreSQL lo comprueba con un índice GiST que crea para la restricción, así que sigue siendo rápido aunque la tabla crezca. - where (status = 'confirmed') limita la regla a las reservas vivas: cancelar una libera su hueco sin borrar la fila, y el historial se conserva. - btree_gist es la extensión que deja a GiST indexar valores normales junto a los rangos. Con un solo calendario no hace falta de verdad; con varios recursos sí (más abajo). Repite ahora las dos peticiones. Las dos leen el hueco libre, las dos insertan, y PostgreSQL acepta la primera y rechaza la segunda con el error 23P01, exclusion_violation. La carrera sigue existiendo; simplemente ahora tiene árbitro.
el código de PostgreSQL cuando una fila incumple la restricción de exclusión23P01lo que responde entonces nuestra API: el hueco está ocupado409rangos semiabiertos, para que una reserva empiece cuando acaba otra[ )
## De un error de la base de datos a una respuesta clara

Una violación de la restricción solo sirve si la aplicación la convierte en algo que una persona entienda. Nuestra API (Rust, con sqlx) traduce ese código concreto a un conflicto y deja pasar cualquier otro error de la base de datos como lo que es:

const EXCLUSION_VIOLATION: &str = "23P01"; fn slot_taken_on_overlap(error: sqlx::Error) -> ApiError { let overlapped = error .as_database_error() .and_then(|database| database.code()) .is_some_and(|code| code == EXCLUSION_VIOLATION); if overlapped { return ApiError::slot_taken(); // 409 SLOT_TAKEN } error.into() }La web decide por el estado, nunca por el mensaje: ante un 409, la [página de reservas](https://alhertech.com/es/agendar-llamada/) avisa de que ese hueco se acaba de ocupar y recarga los libres, así que quien reserva elige otro sin empezar de cero. Mover una reserva a otra hora pasa por las mismas comprobaciones y es una actualización bajo la misma restricción, así que tampoco puede caer encima de otra.

## Lo que la aplicación sigue comprobando antes

La restricción tiene la última palabra, no la única. Antes de insertar, la API calcula si la hora pedida se puede reservar, y esas reglas viven en la aplicación porque cambian con los ajustes y con el mundo de fuera:

- El horario de cada día de la semana, una antelación mínima, cuántos días por delante se puede reservar, un máximo por día y los días bloqueados, todo ajustes del panel y no código.
- Un margen alrededor de cada reserva (15 minutos por defecto), para que nunca haya dos llamadas seguidas.
- Lo que el Google Calendar del equipo dice que está ocupado. Si no se puede leer, la API lo deja anotado y ofrece huecos solo con las reservas, en vez de cerrar la página.
- No más de cinco reservas por hora desde el mismo cliente, que responde 429 y evita que un script llene el calendario.

**Dos capas, dos trabajos**

La aplicación decide qué se ofrece y responde bien y rápido. La base de datos garantiza lo único que no puede pasar nunca. Si alguna vez no están de acuerdo, gana la base de datos, y de eso se trata.

## Lo que la restricción no cubre

Ser preciso con los límites también es diseño. Nuestra restricción protege las reservas, no el margen: el margen lo comprueba la aplicación. Dos peticiones que compiten por huecos vecinos podrían, en teoría, confirmarse más cerca de lo que pide el margen. Nunca se solaparían, que es el fallo que importa, y aceptamos ese compromiso a propósito, porque el margen es un ajuste y una restricción lo congelaría en el esquema.

Si el margen tuviera que ser absoluto, el rango de la restricción puede llevarlo: tstzrange(starts_at - interval '15 minutes', ends_at + interval '15 minutes'). El precio es una migración cada vez que cambie. Lo mismo vale para el máximo diario: es una política, que se aplica en el código, no una invariante de los datos.

## Retener un hueco mientras alguien paga es otro problema

Una llamada queda confirmada en cuanto se guarda. Un alquiler no: entre elegir las fechas y que el pago se complete puede pasar un minuto o dos, y en ese tiempo nadie más debería poder quedarse el hueco. En [Boathous](https://alhertech.com/es/blog/caso-estudio-boathous/), el marketplace de alquiler de barcos que construimos, el hueco se retiene cinco minutos mientras el cliente paga, la comprobación y la retención ocurren en un solo paso atómico, y la confirmación del pago comprueba que la retención es de quien paga.

Se guarde donde se guarde la retención, importan tres cosas: comprobar y retener en una sola operación atómica, una caducidad para que un pago abandonado libere el hueco y una última comprobación al confirmar. Una variante apoyada en la base de datos es guardar la retención como una reserva con su propio estado y su caducidad, bajo la misma restricción de exclusión, para que el árbitro sea el mismo en los dos casos.

## Varios barcos, salas o personas

Casi todos los sistemas reales reservan más de una cosa. La restricción dice entonces que dos reservas chocan solo si son del mismo recurso y sus horas se solapan, y esa igualdad sobre una columna normal es justo lo que btree_gist hace indexable junto al rango:

exclude using gist ( boat_id with =, tstzrange(starts_at, ends_at) with && ) where (status = 'confirmed')Si un sistema de reservas es el corazón de lo que estás construyendo, esta es la clase de decisión que tomamos con el equipo en las primeras semanas de un proyecto de [software a medida](https://alhertech.com/es/servicios/desarrollo-software-a-medida/): escribir qué reglas no se pueden romper nunca y poner cada una donde nadie se la pueda saltar.

## Preguntas

### ¿Basta un índice único sobre la hora de inicio para evitar dobles reservas?

Solo si todas las reservas duran lo mismo y empiezan en la misma rejilla. En cuanto las duraciones cambian o una reserva se puede mover, dos reservas pueden solaparse sin empezar en el mismo instante y un índice único no lo ve. Una restricción de exclusión compara rangos, así que detecta cualquier solape.

### ¿Por qué no usar SELECT ... FOR UPDATE?

Un hueco libre no tiene fila que bloquear, así que bloquear las reservas que lees no impide que alguien inserte una nueva. Habría que bloquear una fila padre, como el calendario o el barco, lo que funciona pero pone en fila todas las reservas de ese recurso y depende de que cada camino del código se acuerde de bloquear. La restricción aguanta incluso el camino que se olvida.

### ¿Lo resolvería el nivel de aislamiento SERIALIZABLE?

Sí, PostgreSQL abortaría una de las dos transacciones con un fallo de serialización (40001). Pero entonces cada escritura tiene que estar preparada para reintentar, y la regla no aparece en ningún sitio del esquema. La restricción de exclusión la escribe una vez, junto a los datos.

### ¿Funciona en MySQL?

MySQL no tiene restricciones de exclusión. Las opciones habituales allí son bloquear una fila padre por recurso o modelar el calendario como una tabla de huecos fijos con una clave única por hueco y recurso.

## Fuentes

- [PostgreSQL: Exclusion constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION)
- [PostgreSQL: Range types](https://www.postgresql.org/docs/current/rangetypes.html)
- [PostgreSQL: btree_gist](https://www.postgresql.org/docs/current/btree-gist.html)
- [PostgreSQL: Error codes (23P01 exclusion_violation)](https://www.postgresql.org/docs/current/errcodes-appendix.html)

## Sigue leyendo

- [Claude Code arregla nuestros despliegues fallidos, y una persona fusiona el arreglo](https://alhertech.com/es/blog/claude-code-arregla-despliegues-fallidos/)
IA · 26 oct 2026 · 10 min de lectura

En Fundaia, nuestra plataforma de despliegue, un despliegue fallido tiene un botón Arreglar con Claude. Cómo el agente diagnostica, reproduce y arregla dentro de un contenedor que solo puede editar ficheros, qué revisa la plataforma antes de abrir el pull request, por qué nunca lo fusiona y qué pasa cuando el agente no consigue arreglar nada.

- [Caso de estudio Leaderbrock: la app de un equipo comercial para la agenda, la ruta y los tickets](https://alhertech.com/es/blog/caso-estudio-leaderbrock/)
Casos de estudio · 26 oct 2026 · 11 min de lectura

El equipo comercial de Leaderbrock trabaja por Europa y América. Cómo sustituimos una hoja de cálculo compartida, un sobre de tickets y la falta de un mapa por un backend reactivo en Java, una app con Expo y un panel web: gastos con la foto que la IA rellena y una persona presenta, una agenda en la hora del móvil y una ruta GPS que distingue un vuelo del ruido.

- [Caso de estudio ReservPark: el móvil que sabe que dejas tu plaza](https://alhertech.com/es/blog/caso-estudio-reservpark/)
Casos de estudio · 26 oct 2026 · 12 min de lectura

ReservPark detecta por el Bluetooth del coche que has aparcado, te pregunta si te vas cuando vuelves a él y avisa a los conductores cercanos, y deja alquilar un garaje por horas o con abono. Cómo construimos la detección nativa, las reglas de la ubicación en segundo plano, las plazas libres de 30 minutos y unas reservas de garaje que no se pueden vender dos veces.

[Blog](https://alhertech.com/es/blog/)
