The complete dynamic QR
toolkit for Django
A self-hosted, production-ready package that gives you everything a commercial QR platform offers — 14 content types, short-URL redirects, scan analytics, access control, and a REST API — fully owned inside your own Django project.
Why django-dynamic-qr
Commercial QR platforms charge monthly fees to do something fundamentally simple: redirect a static-looking QR code through a short URL you control, then log who scanned it. This package gives you that entire feature set as plain Django models, views, and a REST API — no subscriptions, no vendor lock-in, no external dependency on uptime you don't control.
How it works
Every dynamic QR code encodes a short URL pointing back to your Django app — /qr/<slug>/. When scanned, that view runs access checks, logs the scan, then resolves to its destination. Because the printed QR pattern never changes, you can edit where it points to at any time.
# Scan → short URL → access guard → scan log → destination
GET /qr/AbC123Xy/
→ AccessGuard.check() # password, schedule, geo, scan limit
→ ScanLogger.log() # device, geo, referer
→ handler.respond(request) # redirect or landing page
Installation
Add django-dynamic-qr to an existing Django project in under five minutes.
1. Install the package
# Core package
pip install django-dynamic-qr
# With REST API support
pip install django-dynamic-qr[api]
# With Celery async scan logging
pip install django-dynamic-qr[celery]
2. Add to INSTALLED_APPS
INSTALLED_APPS = [
...
"django_dynamic_qr",
"rest_framework", # optional — only if using the REST API
]
3. Mount the URLs
from django.urls import path, include
urlpatterns = [
...
path("", include("django_dynamic_qr.urls")),
]
This mounts /qr/<slug>/ for redirects, /qr/<slug>/export/ for image downloads, and /api/qr/... for the REST API (if DRF is installed).
4. Run migrations
python manage.py migrate django_dynamic_qr
MEDIA_URL / MEDIA_ROOT configured, and your urls.py must serve media in development.Quickstart
Create your first dynamic QR code and watch its destination update without reprinting anything.
Create a QR code
from django_dynamic_qr.models import QRCode
qr = QRCode.objects.create(
user=request.user,
name="My Website",
qr_type="url",
type_data={"url": "https://example.com"},
fg_color="#111111",
bg_color="#FFFFFF",
)
print(qr.get_redirect_url())
# → https://yourdomain.com/qr/AbC123Xy/
Export the image
from django_dynamic_qr.engine.exporter import export_qr_code
png_bytes = export_qr_code(qr, fmt="png")
svg_bytes = export_qr_code(qr, fmt="svg")
Or fetch it directly via the built-in view:
GET /qr/AbC123Xy/export/?fmt=svg
Update the destination — no reprint needed
This is the entire point of a dynamic QR code. The printed pattern, slug, and image stay byte-for-byte identical — only the database row changes.
qr.type_data = {"url": "https://example.com/new-page"}
qr.save()
# Anyone scanning the same printed code now lands on the new URL.
Configuration
All settings are optional with sane defaults — override only what you need in a single dict.
DJANGO_DYNAMIC_QR = {
"SHORT_URL_BASE": "https://yourdomain.com",
"DEFAULT_ERROR_CORRECTION": "H",
"DEFAULT_BOX_SIZE": 10,
"DEFAULT_BORDER": 4,
"LOGO_MAX_RATIO": 0.25,
"SCAN_LOG_ASYNC": False,
"GEO_BACKEND": "ip-api",
"MAXMIND_DB_PATH": None,
"ENABLE_REST_API": True,
"UPLOAD_TO": "qr_codes/",
"LOGO_UPLOAD_TO": "qr_logos/",
}
Settings reference
| Key | Default | Description |
|---|---|---|
| SHORT_URL_BASE | "" | Production domain used to build redirect URLs. Set this before deploying. |
| DEFAULT_ERROR_CORRECTION | "H" | L / M / Q / H — higher tolerates more logo overlay and damage. |
| DEFAULT_BOX_SIZE | 10 | Pixel size of each QR module in PNG output. |
| LOGO_MAX_RATIO | 0.25 | Maximum proportion of the QR area a center logo may occupy. |
| SCAN_LOG_ASYNC | False | Offload scan logging to a Celery task instead of inline writes. |
| GEO_BACKEND | "ip-api" | ip-api · maxmind · None — IP geolocation provider for scan logs. |
| ENABLE_REST_API | True | Mount DRF viewsets at /api/qr/ when djangorestframework is installed. |
Architecture
A modular package structure where every QR content type is a pluggable handler.
django_dynamic_qr/
├── models/ # QRCode, ScanLog, Folder
├── types/ # 14 type handler classes + registry
├── engine/ # QR matrix generation, exporters
├── analytics/ # geo lookup, scan logger, CSV/XLSX export
├── access/ # password, schedule, scan-limit, geo guards
├── bulk/ # CSV importer, ZIP exporter
├── api/ # DRF serializers + viewsets
├── views/ # redirect view, export view
└── management/ # generate_qr command
Request lifecycle
Every scan passes through three stages before resolving to its final content:
- Access Guard — checks password, schedule, daily scan limit, and geo allow-list, in that order.
- Scan Logger — records IP-derived geo, device, OS, browser, and referer (sync or async via Celery).
- Type Handler — either issues an HTTP redirect (URL, PDF, WiFi) or renders a hosted landing page (vCard+, Menu, Social Links).
The handler pattern
Every QR type is a subclass of BaseQRType registered in a central dict. This is the extension point used in Custom QR Types.
class BaseQRType:
type_id: str = None
is_static: bool = False
has_landing_page: bool = False
def get_destination(self) -> str: ...
def to_qr_content(self) -> str: ...
def respond(self, request): ...
Models
Three models drive the entire package: QRCode, ScanLog, and Folder.
QRCode
The central model. Holds identity, design, type-specific JSON payload, and access-control fields.
| Field | Type | Notes |
|---|---|---|
| slug | SlugField | Auto-generated, URL-safe, unique — the printed QR content. |
| qr_type | CharField | One of the 14 registered type IDs. |
| type_data | JSONField | Per-type payload — see QR Types for schema. |
| fg_color / bg_color | CharField | Hex colors for the QR matrix. |
| logo | ImageField | Optional center logo overlay. |
| error_correction | CharField | L / M / Q / H. |
| password | CharField | Blank = no password gate. |
| scan_limit_day | PositiveIntegerField | Null = unlimited. |
| allowed_countries | JSONField | ISO 3166-1 alpha-2 list, e.g. ["IN","US"]. |
ScanLog
One row per scan, foreign-keyed to QRCode, indexed on (qr_code, scanned_at) and country for fast analytics queries.
scanned_at, ip_address, country, city,
device, os, browser, referer, user_agent
Folder
Simple per-user grouping with a color tag, used in the admin and API for organizing large QR collections.
QR Types
14 built-in content handlers, each with its own type_data payload schema.
Payload reference
| Type | type_data keys |
|---|---|
| url | url |
| vcard | first_name, last_name, phone, email, company, title, address, website |
| vcard_plus | same as vcard + photo_url, bio, socials |
pdf_url, title | |
| menu | restaurant_name, logo_url, categories[] |
| social | title, bio, avatar_url, links[] |
| wifi | ssid, password, security, hidden |
to, subject, body | |
| sms | phone, message |
phone, message | |
| image | title, images[] |
| text | text |
| link_list | title, description, links[] |
| app_store | ios_url, android_url, fallback_url |
Static vs. dynamic types
Most types encode a short URL and resolve server-side. A few — WiFi, Email, SMS, WhatsApp, Plain Text — are marked is_static = True and encode their payload directly into the QR matrix, since their content is meant to work fully offline.
has_landing_page = True on their handler.Design Engine
Custom colors, logo overlays, and multi-format export powered by qrcode and segno.
Export formats
| Format | Engine | Use case |
|---|---|---|
| PNG | qrcode + Pillow | Default, supports logo overlay. |
| SVG | segno | Infinitely scalable, ideal for print. |
| segno | Direct embed into print-ready documents. | |
| EPS | segno | Professional print/publishing workflows. |
Logo overlay
Logos are centered and automatically scaled to LOGO_MAX_RATIO of the QR area (default 25%), paired with H error correction so the code stays scannable despite the overlay.
def generate_qr(
content: str,
fg_color: str = "#000000",
bg_color: str = "#FFFFFF",
logo_path: str = None,
fmt: str = "png",
) -> bytes: ...
Error correction levels
| Level | Recovery | Recommended for |
|---|---|---|
| L | ~7% | Clean digital display, no logo. |
| M | ~15% | General use. |
| Q | ~25% | Print with minor wear expected. |
| H | ~30% | Default — required for logo overlays. |
Analytics
Every scan is logged with device, geography, and referer data — exportable to CSV or XLSX.
What's captured per scan
- Timestamp, IP address (used only for geo lookup, not stored raw beyond the field)
- Country & city via
ip-api(free) or MaxMind GeoLite2 (self-hosted DB) - Device class — mobile / tablet / desktop, parsed via
user-agents - OS and browser family
- HTTP referer, when present
Sync vs. async logging
By default scans are logged inline on the request. Set SCAN_LOG_ASYNC: True with Celery configured to offload writes to a background task and keep redirects fast under load.
@shared_task(ignore_result=True)
def log_scan_task(qr_pk: int, data: dict):
qr = QRCode.objects.get(pk=qr_pk)
ScanLog.objects.create(qr_code=qr, **data)
Exporting scan logs
from django_dynamic_qr.analytics.exporters import export_scan_csv, export_scan_xlsx
response = export_scan_csv(qr) # HttpResponse, CSV
response = export_scan_xlsx(qr) # HttpResponse, styled XLSX
Access Control
Four independent guards run in sequence before a scan is logged or resolved.
qr.password. Shows an interstitial password page on scan.active_from / active_until datetimes gate availability.scan_limit_day caps scans per calendar day, counted from ScanLog.allowed_countries restricts access by IP-derived country code.Evaluation order
Guards run as: active → schedule → geo → scan limit → password. The first failing guard returns a dedicated denial page (e.g. expired.html, scan_limit.html) and halts the request before any scan is logged.
qr.password = "secret123"
qr.scan_limit_day = 500
qr.active_until = timezone.now() + timedelta(days=30)
qr.allowed_countries = ["IN", "US", "GB"]
qr.save()
Bulk Operations
Import QR codes from CSV, export selected sets as a ZIP archive.
CSV import
Required columns: name, qr_type, and a data column containing a JSON string of the type payload.
name,qr_type,data
My Website,url,"{""url"": ""https://example.com""}"
Office WiFi,wifi,"{""ssid"": ""OfficeNet"", ""password"": ""pass123""}"
from django_dynamic_qr.bulk import import_from_csv
created, errors = import_from_csv(csv_file, user=request.user)
ZIP export
from django_dynamic_qr.bulk import export_zip
response = export_zip(qr_codes, fmt="svg")
Django Admin
A fully wired admin interface with live previews and bulk actions out of the box.
Features
- Inline live QR thumbnail preview rendered as base64 PNG
- Inline scan log table per QR code (last 20 scans)
- Bulk actions: export PNG/ZIP, export SVG ZIP, export scan CSV, activate/deactivate
- Collapsible access-control fieldset to keep the form uncluttered
@admin.register(QRCode)
class QRCodeAdmin(admin.ModelAdmin):
list_display = ["name", "qr_type", "is_active", "total_scans", "qr_preview"]
actions = [export_png, export_svg, export_scans_csv, activate_qr, deactivate_qr]
inlines = [ScanLogInline]
Custom QR Types
Register your own handler class for content types not covered out of the box.
from django_dynamic_qr.types.base import BaseQRType
from django_dynamic_qr.types import registry
class CalendarEventType(BaseQRType):
type_id = "calendar_event"
is_static = True
def get_destination(self):
d = self.data
return f"BEGIN:VEVENT\nSUMMARY:{d['title']}\nDTSTART:{d['start']}\nEND:VEVENT"
registry["calendar_event"] = CalendarEventType
Import this module inside your app's AppConfig.ready() so the registration runs at Django startup.
API Overview
A full Django REST Framework viewset mounted at /api/qr/, requiring authentication.
Authentication
Uses session or token authentication depending on your REST_FRAMEWORK settings. All endpoints are scoped to request.user — users only ever see their own QR codes.
Base URL
https://yourdomain.com/api/qr/QR Codes
CRUD operations on QR code resources.
List all QR codes owned by the authenticated user. Supports ?folder= and ?type= query filters.
Create a new QR code.
{
"name": "My Website",
"qr_type": "url",
"type_data": { "url": "https://example.com" },
"fg_color": "#111111"
}
Retrieve a single QR code.
Update fields — including type_data to change the redirect destination dynamically.
Permanently remove a QR code and its associated scan logs.
Download the QR image. fmt accepts png, svg, pdf, eps.
Analytics Endpoints
Pull scan summaries and raw logs for a given QR code.
Returns total scans, today's scan count, top countries, top devices, and a 30-day daily trend.
{
"total_scans": 1842,
"scans_today": 37,
"top_countries": [["IN", 1203], ["US", 410]],
"top_devices": [["mobile", 1601], ["desktop", 241]],
"daily_counts": [["2026-06-28", 52], ...]
}
Download raw scan logs. fmt accepts csv or xlsx.
Bulk Endpoints
Import and export many QR codes in a single request.
Multipart upload with a file field containing a CSV. Returns {"created": N, "errors": [...]}.
Returns a ZIP of QR images for the given IDs (or all owned QR codes if no id params are passed).
CLI Commands
Management commands for scripting and CI workflows.
generate_qr
Export a QR code image by slug, directly from the command line.
python manage.py generate_qr AbC123Xy --fmt svg --output qr.svg
| Flag | Default | Description |
|---|---|---|
slug | — | Required positional argument. |
--fmt | png | png / svg / pdf / eps |
--output | stdout | File path to save to. |
FAQ
Common questions about django-dynamic-qr.
Does updating a QR code's destination require reprinting?
No — that's the entire premise of a dynamic QR code. The printed pattern encodes a short URL, not the destination itself, so editing type_data changes where scans land instantly.
Can I self-host geolocation instead of using ip-api.com?
Yes — set GEO_BACKEND: "maxmind" and point MAXMIND_DB_PATH at a local GeoLite2 City database file.
Is Celery required?
No. It's entirely optional — install the celery extra and set SCAN_LOG_ASYNC: True only if you want scan logging offloaded from the request/response cycle.
Can I add my own QR content type?
Yes — see Custom QR Types. Subclass BaseQRType and register it in the type registry.
What error correction level should I use with a logo?
H (≈30% recovery) is the default and recommended minimum when overlaying a center logo, since the logo physically obscures part of the matrix.
Creator
django-dynamic-qr is built and maintained by Rahul Baberwal.
Background
Rahul is an MSc Computer Science student at Mohta College, MGSU University Bikaner, with a Major in Artificial Intelligence from IIT Ropar. He currently works as a Backend Developer at Groww Per Click, building scalable backend systems and REST APIs in a fintech environment, after gaining a strong IT foundation through NIELIT's O Level certification.
Specializes in
- Python, Django, Django REST Framework, FastAPI
- PostgreSQL, Redis, Celery, Docker
- Machine learning — scikit-learn, Pandas, NumPy, NLP/SBERT