Ticketing Platform Development
Ticketing platform is a system with strict requirements for data consistency. Several users simultaneously buying the last ticket in row A. Race condition in this case means seats sold "twice" — and refunded money, plus damaged reputation. Correct architecture starts with choosing a locking mechanism.
Seat Model and Reservations
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organizer_id UUID NOT NULL REFERENCES users(id),
title VARCHAR(300) NOT NULL,
slug VARCHAR(300) UNIQUE NOT NULL,
venue_id UUID REFERENCES venues(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','on_sale','sold_out','cancelled','completed')),
timezone VARCHAR(50) NOT NULL DEFAULT 'Europe/Moscow',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ticket_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id),
name VARCHAR(200) NOT NULL, -- 'VIP', 'Standard', 'Student'
price NUMERIC(12,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'RUB',
total_qty INTEGER NOT NULL,
reserved_qty INTEGER NOT NULL DEFAULT 0,
sold_qty INTEGER NOT NULL DEFAULT 0,
sale_starts_at TIMESTAMPTZ,
sale_ends_at TIMESTAMPTZ,
max_per_order INTEGER NOT NULL DEFAULT 10,
CONSTRAINT qty_valid CHECK (sold_qty + reserved_qty <= total_qty)
);
CREATE TABLE seats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_type_id UUID NOT NULL REFERENCES ticket_types(id),
section VARCHAR(50),
row VARCHAR(10),
number VARCHAR(10),
status VARCHAR(20) NOT NULL DEFAULT 'available'
CHECK (status IN ('available','reserved','sold','blocked')),
reserved_until TIMESTAMPTZ, -- when reservation expires
order_id UUID,
UNIQUE (ticket_type_id, section, row, number)
);
CREATE INDEX idx_seats_available
ON seats(ticket_type_id, status)
WHERE status = 'available';
Optimistic Seat Locking
Naive SELECT → UPDATE will cause race condition. Correct approach — SELECT FOR UPDATE SKIP LOCKED:
from django.db import transaction
from django.utils import timezone
from datetime import timedelta
def reserve_seats(ticket_type_id: str, qty: int, session_id: str) -> list:
"""
Reserve N seats for 15 minutes for purchase session.
Returns list of seat_id or raises if insufficient.
"""
with transaction.atomic():
# Lock rows without waiting — other transactions skip locked rows
seats = (
Seat.objects
.select_for_update(skip_locked=True)
.filter(
ticket_type_id=ticket_type_id,
status='available'
)
.order_by('section', 'row', 'number')[:qty]
)
if len(seats) < qty:
raise InsufficientSeatsError(
f'Available {len(seats)} seats, requested {qty}'
)
seat_ids = [seat.id for seat in seats]
reserved_until = timezone.now() + timedelta(minutes=15)
Seat.objects.filter(id__in=seat_ids).update(
status='reserved',
reserved_until=reserved_until,
order_id=None, # will be filled after payment
)
TicketType.objects.filter(id=ticket_type_id).update(
reserved_qty=F('reserved_qty') + qty
)
# Save in Redis for quick access
redis_client.setex(
f'reservation:{session_id}:{ticket_type_id}',
900, # 15 minutes in seconds
json.dumps(seat_ids)
)
return seat_ids
Releasing Expired Reservations
@shared_task
def release_expired_reservations():
"""Celery beat: every 2 minutes"""
now = timezone.now()
expired_seats = Seat.objects.filter(
status='reserved',
reserved_until__lt=now,
order_id__isnull=True
)
seat_ids = list(expired_seats.values_list('id', flat=True))
if not seat_ids:
return
# Group by ticket_type to update counts
type_counts = (
Seat.objects
.filter(id__in=seat_ids)
.values('ticket_type_id')
.annotate(cnt=Count('id'))
)
with transaction.atomic():
Seat.objects.filter(id__in=seat_ids).update(
status='available',
reserved_until=None,
)
for row in type_counts:
TicketType.objects.filter(id=row['ticket_type_id']).update(
reserved_qty=F('reserved_qty') - row['cnt']
)
# Notify via WebSocket — tickets available again
for row in type_counts:
channel_layer.group_send(
f'event_{row["ticket_type_id"]}',
{'type': 'seats_released', 'count': row['cnt']}
)
Ticket QR Code and Entry Verification
import qrcode
import hmac
import hashlib
from io import BytesIO
def generate_ticket_token(ticket_id: str, secret: str) -> str:
"""HMAC-signed token for QR code"""
msg = f'ticket:{ticket_id}'.encode()
signature = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return f'{ticket_id}:{signature[:16]}'
def verify_ticket_token(token: str, secret: str) -> str | None:
"""Returns ticket_id if token is valid, else None"""
parts = token.split(':')
if len(parts) != 2:
return None
ticket_id, provided_sig = parts
expected_token = generate_ticket_token(ticket_id, secret)
expected_sig = expected_token.split(':')[1]
if hmac.compare_digest(provided_sig, expected_sig):
return ticket_id
return None
def generate_qr_image(ticket) -> bytes:
token = generate_ticket_token(str(ticket.id), settings.TICKET_SECRET)
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_H)
qr.add_data(f'https://tickets.site.com/verify/{token}')
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = BytesIO()
img.save(buf, format='PNG')
return buf.getvalue()
API for entry scanners:
# POST /api/v1/tickets/verify
def verify_ticket_view(request):
token = request.data.get('token')
ticket_id = verify_ticket_token(token, settings.TICKET_SECRET)
if not ticket_id:
return Response({'valid': False, 'reason': 'invalid_token'}, status=400)
ticket = Ticket.objects.select_related('event', 'seat').get(id=ticket_id)
if ticket.scanned_at:
return Response({
'valid': False,
'reason': 'already_used',
'scanned_at': ticket.scanned_at.isoformat(),
}, status=400)
if ticket.event.starts_at.date() != date.today():
return Response({'valid': False, 'reason': 'wrong_date'}, status=400)
ticket.scanned_at = timezone.now()
ticket.save()
return Response({
'valid': True,
'holder': ticket.holder_name,
'seat': f'{ticket.seat.section} row {ticket.seat.row} seat {ticket.seat.number}',
'ticket_type': ticket.ticket_type.name,
})
Dynamic Pricing
Price increases as hall fills — standard practice for concerts:
def get_current_price(ticket_type) -> Decimal:
"""Dynamic price based on % of sold tickets"""
sold_ratio = ticket_type.sold_qty / ticket_type.total_qty
# Fill thresholds
tiers = [
(0.0, 0.0), # 0–50%: base price
(0.5, 0.10), # 50–70%: +10%
(0.7, 0.25), # 70–85%: +25%
(0.85, 0.50), # 85–95%: +50%
(0.95, 1.00), # 95–100%: +100%
]
multiplier = Decimal('1.0')
for threshold, increase in reversed(tiers):
if sold_ratio >= threshold:
multiplier = Decimal(str(1 + increase))
break
return (ticket_type.base_price * multiplier).quantize(Decimal('1'))
Payment Flow Schema
- User selects seats →
reserve_seats()→ 15-minute hold - Proceed to payment → create
Orderwithpendingstatus - Payment via YooKassa or Stripe → webhook confirms
-
Order.status = 'paid'→ seats move tosold→ generate PDF tickets - Email with tickets → PDF attachment + QR code
Timeline
Basic ticketing platform without seating map (only ticket types, quantity): 4–6 weeks. Full system with interactive seating map (SVG), dynamic pricing, scanner and organizer dashboard: 3–4 months.







