A restaurant table management system sounds simple until you add reservations that overlap, walk-ins that need the same tables, and kitchen staff who need to see status changes in real time. Here is how we build this backend in Go for restaurant operators in Lebanon and MENA.
A restaurant table management system sounds simple until you add reservations that overlap, walk-ins that need the same tables, and kitchen staff who need to see status changes in real time. The core data model is deceptively small. The edge cases in a live restaurant environment make it genuinely hard. This is how we build this backend in Go for restaurant operators in Lebanon and MENA.
The core data model
Three main entities: tables, reservations, and table assignments.
CREATE TABLE tables (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
capacity INT NOT NULL,
section TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE reservations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
guest_name TEXT NOT NULL,
guest_phone TEXT,
party_size INT NOT NULL,
reserved_for TIMESTAMPTZ NOT NULL,
duration_mins INT NOT NULL DEFAULT 90,
notes TEXT,
status TEXT NOT NULL DEFAULT 'confirmed',
source TEXT NOT NULL DEFAULT 'phone',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_status CHECK (status IN ('confirmed','seated','completed','cancelled','no_show'))
);
CREATE TABLE table_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
reservation_id UUID REFERENCES reservations(id),
table_id UUID NOT NULL REFERENCES tables(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
assignment_type TEXT NOT NULL DEFAULT 'reservation',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_type CHECK (assignment_type IN ('reservation','walk_in'))
);
CREATE INDEX idx_assignments_table_time
ON table_assignments(table_id, starts_at, ends_at)
WHERE ends_at > now();
The table_assignments table is the source of truth for what table is occupied during which time window. Both reservations and walk-ins create assignments. The separation between reservations and table_assignments allows a single reservation to span multiple tables for large parties, and allows re-assigning a reservation to a different table without losing the reservation record.
Conflict detection
The critical query: before assigning a table, check whether any existing assignment overlaps the requested time window.
func (r *TableRepository) HasConflict(
ctx context.Context,
tenantID, tableID uuid.UUID,
startsAt, endsAt time.Time,
excludeAssignmentID *uuid.UUID,
) (bool, error) {
query := `
SELECT EXISTS (
SELECT 1 FROM table_assignments
WHERE tenant_id = $1
AND table_id = $2
AND starts_at < $4
AND ends_at > $3
AND ($5::uuid IS NULL OR id != $5)
)
`
var exists bool
err := r.db.QueryRowContext(ctx, query,
tenantID, tableID, startsAt, endsAt, excludeAssignmentID,
).Scan(&exists)
return exists, err
}
The time overlap condition starts_at < ends_at_new AND ends_at > starts_at_new is the standard interval overlap check. The excludeAssignmentID parameter handles the case where you are updating an existing assignment: exclude the current assignment from the conflict check so it does not conflict with itself.
This check must run inside a transaction with the insert, using SELECT ... FOR UPDATE on the table record to prevent concurrent requests from both passing the conflict check and both inserting:
func (r *TableRepository) AssignTable(ctx context.Context, a TableAssignment) error {
return r.db.WithTx(ctx, func(tx *sql.Tx) error {
// Lock the table row to prevent concurrent conflict bypasses
_, err := tx.ExecContext(ctx,
`SELECT 1 FROM tables WHERE id = $1 AND tenant_id = $2 FOR UPDATE`,
a.TableID, a.TenantID,
)
if err != nil {
return fmt.Errorf("lock table: %w", err)
}
// Check for conflicts
conflict, err := r.hasConflictTx(ctx, tx, a.TenantID, a.TableID, a.StartsAt, a.EndsAt, nil)
if err != nil {
return err
}
if conflict {
return ErrTableConflict
}
// Insert assignment
_, err = tx.ExecContext(ctx,
`INSERT INTO table_assignments
(tenant_id, reservation_id, table_id, starts_at, ends_at, assignment_type)
VALUES ($1, $2, $3, $4, $5, $6)`,
a.TenantID, a.ReservationID, a.TableID, a.StartsAt, a.EndsAt, a.Type,
)
return err
})
}
Walk-in handling
Walk-ins are assignments without a reservation. They are created when a guest arrives without a booking. The duration is estimated (typically 60-90 minutes for a casual meal, 120 for a full-service experience). Operations staff can extend the duration if the table is still occupied.
func (s *TableService) SeatWalkIn(
ctx context.Context,
tenantID uuid.UUID,
tableID uuid.UUID,
partySize int,
estimatedDurationMins int,
) (*TableAssignment, error) {
now := time.Now()
endsAt := now.Add(time.Duration(estimatedDurationMins) * time.Minute)
a := TableAssignment{
TenantID: tenantID,
TableID: tableID,
StartsAt: now,
EndsAt: endsAt,
Type: "walk_in",
}
if err := s.tables.AssignTable(ctx, a); err != nil {
if errors.Is(err, ErrTableConflict) {
return nil, ErrTableNotAvailable
}
return nil, err
}
return &a, nil
}
Querying available tables for a time window
When taking a reservation or checking availability for a walk-in, you need all tables with sufficient capacity that have no conflicting assignments:
const availableTablesQuery = `
SELECT t.id, t.name, t.capacity, t.section
FROM tables t
WHERE t.tenant_id = $1
AND t.capacity >= $2
AND t.is_active = true
AND NOT EXISTS (
SELECT 1 FROM table_assignments a
WHERE a.table_id = t.id
AND a.tenant_id = t.tenant_id
AND a.starts_at < $4
AND a.ends_at > $3
)
ORDER BY t.capacity ASC
`
Ordering by capacity ascending returns the smallest table that fits the party first, which is important for table optimization: seat a 2-top at a 2-person table rather than a 6-person table.
Real-time status for the floor plan view
The floor plan view shows all tables with their current status. A table is available, occupied (walk-in), reserved-soon (upcoming assignment within 30 minutes), or reserved (assignment exists but the guests have not arrived yet).
SELECT
t.id,
t.name,
t.capacity,
t.section,
CASE
WHEN a_now.id IS NOT NULL THEN 'occupied'
WHEN a_soon.id IS NOT NULL THEN 'reserved_soon'
WHEN a_future.id IS NOT NULL THEN 'reserved'
ELSE 'available'
END AS status,
a_now.reservation_id,
a_soon.starts_at AS next_reservation_at
FROM tables t
LEFT JOIN table_assignments a_now
ON a_now.table_id = t.id AND a_now.tenant_id = t.tenant_id
AND a_now.starts_at <= now() AND a_now.ends_at > now()
LEFT JOIN table_assignments a_soon
ON a_soon.table_id = t.id AND a_soon.tenant_id = t.tenant_id
AND a_soon.starts_at > now() AND a_soon.starts_at <= now() + interval '30 minutes'
LEFT JOIN table_assignments a_future
ON a_future.table_id = t.id AND a_future.tenant_id = t.tenant_id
AND a_future.starts_at > now() + interval '30 minutes'
WHERE t.tenant_id = $1 AND t.is_active = true
ORDER BY t.section, t.name
This query runs on every floor plan refresh. With a reasonable number of tables (up to a few hundred per tenant) and the index on table_assignments(table_id, starts_at, ends_at), it executes in a few milliseconds.
For real-time updates, we use PostgreSQL LISTEN/NOTIFY to push table status changes to connected web clients rather than polling. When an assignment is created, updated, or deleted, a trigger fires a notification that a Go goroutine listens to and forwards over WebSocket.
Key lessons from production
- Use a separate
table_assignmentstable as the source of truth for occupancy, not a status column on the tables table. - Conflict detection must run inside a transaction with a row lock to be race-condition safe.
- Walk-ins and reservations are the same
table_assignmentsrecord with a different type field. - The smallest-table-first availability sort is important for operational efficiency.
- Push status changes over WebSocket rather than polling; PostgreSQL LISTEN/NOTIFY makes this clean in Go.
- Account for duration estimates: set a sensible default and allow extension without creating a new assignment.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
We build operational restaurant systems for clients in Lebanon and across MENA. If you are building POS or reservation features and want to get the architecture right, reach out at https://voxire.com/get-a-quote/
Voxire
SaaS Product Development
From idea to launched product - strategy, architecture, full-stack development, and post-launch support.
Learn more


