Get a quote

Building a Restaurant Table Management and Reservation System in Go

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_assignments table 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_assignments record 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.
Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

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
Back to blog
Chat on WhatsApp