Most teams running Go microservices reach a point where they need capabilities that Go handles less gracefully than they expected. A backoffice admin interface. A workflow that involves complex data relationships and rapid iteration on business rules. A reporting pipeline where the query logic changes weekly and nobody wants to recompile a binary every time. These are not architecture failures. They are the natural edges of what Go is optimised for.
Django fills those edges well. The ORM handles complex relational queries without ceremony. The admin interface is production-ready in a few dozen lines of configuration. The ecosystem for data processing, PDF generation, email templating and async task execution is mature and well-maintained. Python's iteration speed on data-heavy problems is meaningfully faster than Go's.
I have run this combination in production: Go services handling the high-throughput, latency-sensitive paths, and a Django service handling the operational and reporting surfaces that the Go services expose data for. The integration works cleanly when the boundaries are clear and the contracts between services are explicit. It creates problems when Django starts reaching into Go service databases or Go services start calling Django endpoints for anything on the critical path.
This article is about how to draw those boundaries correctly.
When Django Belongs in a Go Architecture
The decision to add a Django service should follow from a clear capability gap, not from familiarity or convenience. The cases where Django earns its place alongside Go services are specific.
Django belongs when you need a backoffice or admin interface with complex data relationships. Go does not have an equivalent to Django Admin. Building one from scratch in Go is a significant investment with no leverage from the ecosystem. Django Admin, configured properly, can handle CRUD operations, filtering, search and custom actions for dozens of models in a day of work.
Django belongs when a domain has rapidly evolving business logic that is expressed more naturally in Python. Data transformation pipelines, rules engines, financial report generation: these change frequently, involve complex conditional logic, and benefit from Python's expressiveness and the available libraries. Recompiling and redeploying a Go binary every time a business rule changes is a friction cost that accumulates.
Django belongs when you need Celery for async task processing with scheduling, retry logic and task monitoring. Go has worker pool patterns and libraries, but Celery's combination of task routing, priority queues, scheduled tasks and a monitoring interface (Flower) is difficult to replicate without significant investment.
Django does not belong on any path that requires low latency. It does not belong as a gateway that Go services route through. It does not belong reading directly from Go service databases. Every one of those placements creates coupling that is expensive to undo.
Project Structure
The Django service lives as a first-class service in the architecture, not a special case. Its project structure reflects that.
services/
payments/ # Go service
accounts/ # Go service
operations/ # Django service
config/
settings/
base.py
production.py
development.py
urls.py
celery.py
wsgi.py
asgi.py
apps/
accounts/ # Read model for account data
payments/ # Read model for payment data
reports/ # Reporting and export
admin/ # Custom admin configuration
tests/
Dockerfile
requirements/
base.txt
production.txt
development.txt
manage.py
Makefile
Settings split by environment. Requirements split by environment. Apps named by the domain they serve, not by technical function. The admin app contains custom Django Admin configuration rather than scattering it across domain apps.
Split settings from day one. A single settings.py that conditionally branches on environment variables becomes unreadable under any meaningful operational complexity.
# config/settings/base.py
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third party
"rest_framework",
"django_celery_beat",
"django_celery_results",
# Internal
"apps.accounts",
"apps.payments",
"apps.reports",
"apps.admin",
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["DB_NAME"],
"USER": os.environ["DB_USER"],
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": os.environ["DB_HOST"],
"PORT": os.environ.get("DB_PORT", "5432"),
"CONN_MAX_AGE": 60,
"OPTIONS": {"sslmode": os.environ.get("DB_SSLMODE", "require")},
}
}
# config/settings/production.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
CELERY_BROKER_URL = os.environ["CELERY_BROKER_URL"]
CELERY_RESULT_BACKEND = "django-db"
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json",
}
},
"root": {"handlers": ["console"], "level": "INFO"},
}
JSON logging in production, matching the format your Go services use, means your log aggregator processes all services the same way. Structured logs from Django and Go services appear in the same dashboards with the same query syntax.
Owning Its Own Database
The Django service owns its own database. It does not share a schema with any Go service, and no Go service reads from the Django service's database.
The Django service's database contains two categories of data. First, read models: denormalised representations of data owned by Go services, populated via event consumption. Second, operational data owned exclusively by the Django service: report configurations, scheduled tasks, admin audit logs, user preferences for the backoffice.
# apps/accounts/models.py
from django.db import models
class Account(models.Model):
"""
Read model populated from the accounts service event stream.
This model is never written to by Django business logic.
Write operations happen in the accounts Go service.
"""
id = models.UUIDField(primary_key=True)
owner_id = models.UUIDField(db_index=True)
currency = models.CharField(max_length=3)
balance_cents = models.BigIntegerField()
status = models.CharField(max_length=50)
created_at = models.DateTimeField()
updated_at = models.DateTimeField()
synced_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "accounts_read"
managed = True
@property
def balance_display(self):
major = self.balance_cents // 100
minor = self.balance_cents % 100
return f"{major}.{minor:02d} {self.currency}"
def __str__(self):
return f"Account {self.id} ({self.currency})"
The docstring on the model is not optional. Every read model should state explicitly that it is populated by events and never written to by Django. The next developer inheriting this codebase should not have to infer that from the absence of write operations in the code.
synced_at records when the read model was last updated from the event stream. It is useful for monitoring: if synced_at for a significant portion of records is more than a few minutes behind, the event consumer is lagging and the read model is stale.
Consuming Events from Go Services
Go services publish events when their state changes. The Django service consumes those events and updates its read models. This is the only legitimate data flow from Go services into the Django service's database.
# apps/accounts/consumers.py
import json
import logging
from django.utils import timezone
from .models import Account
logger = logging.getLogger(__name__)
def handle_account_created(event: dict) -> None:
payload = event["payload"]
Account.objects.update_or_create(
id=payload["account_id"],
defaults={
"owner_id": payload["owner_id"],
"currency": payload["currency"],
"balance_cents": payload["balance_cents"],
"status": payload["status"],
"created_at": payload["created_at"],
"updated_at": payload["created_at"],
},
)
logger.info(
"account read model created",
extra={"account_id": payload["account_id"], "event_id": event["id"]},
)
def handle_account_updated(event: dict) -> None:
payload = event["payload"]
updated = Account.objects.filter(id=payload["account_id"]).update(
balance_cents=payload["balance_cents"],
status=payload["status"],
updated_at=payload["updated_at"],
)
if not updated:
logger.warning(
"account not found for update, creating from event",
extra={"account_id": payload["account_id"], "event_id": event["id"]},
)
handle_account_created(event)
update_or_create on handle_account_created makes the consumer idempotent. If the same event is processed twice, the second write produces the same result as the first. Event consumers must be idempotent. Message brokers guarantee at-least-once delivery, not exactly-once.
The handle_account_updated fallback to creation handles the case where an update event arrives before its corresponding creation event, which can happen when a consumer starts from a point in the stream after the creation event was published. Log the anomaly and recover rather than failing.
A Celery task wraps each consumer function to provide retry logic and dead-letter handling:
# apps/accounts/tasks.py
from celery import shared_task
from .consumers import handle_account_created, handle_account_updated
@shared_task(
bind=True,
max_retries=5,
default_retry_delay=10,
queue="events",
acks_late=True,
)
def process_account_event(self, event: dict) -> None:
handlers = {
"account.created": handle_account_created,
"account.updated": handle_account_updated,
}
event_type = event.get("type")
handler = handlers.get(event_type)
if not handler:
# Unknown event type: log and discard, do not retry
import logging
logging.getLogger(__name__).warning(
"unhandled event type", extra={"event_type": event_type}
)
return
try:
handler(event)
except Exception as exc:
raise self.retry(exc=exc)
acks_late=True means the message is only acknowledged after the task completes successfully. If the task fails and does not retry, the message stays in the queue. Combined with max_retries, this gives five chances to process the event before it is sent to the dead-letter queue for manual inspection.
Exposing Data Back to Go Services
The Django service exposes a REST API using Django REST Framework for any data that Go services need from it. Go services call this API; they do not read the Django database directly.
# apps/reports/serializers.py
from rest_framework import serializers
from .models import Report
class ReportSerializer(serializers.ModelSerializer):
class Meta:
model = Report
fields = ["id", "report_type", "status", "generated_at", "download_url"]
read_only_fields = fields
# apps/reports/views.py
from rest_framework import generics, permissions
from .models import Report
from .serializers import ReportSerializer
from .authentication import ServiceTokenAuthentication
class ReportDetailView(generics.RetrieveAPIView):
queryset = Report.objects.all()
serializer_class = ReportSerializer
authentication_classes = [ServiceTokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
lookup_field = "id"
Service-to-service authentication uses shared tokens rather than user sessions. The ServiceTokenAuthentication class validates a static token from the Authorization header. Tokens are rotated via environment variables without code changes.
# apps/reports/authentication.py
import os
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class ServiceTokenAuthentication(BaseAuthentication):
def authenticate(self, request):
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
if not token:
return None
valid_tokens = os.environ.get("SERVICE_API_TOKENS", "").split(",")
if token not in valid_tokens:
raise AuthenticationFailed("invalid service token")
return (ServicePrincipal(token), None)
class ServicePrincipal:
def __init__(self, token: str):
self.token = token
self.is_authenticated = True
SERVICE_API_TOKENS accepts a comma-separated list of valid tokens, which means you can rotate tokens by adding the new token to the list, updating the caller, then removing the old token. Zero-downtime token rotation without a dedicated secrets management system.
The Django Admin as a Production Tool
Django Admin is the primary reason Django earns its place in many Go-based architectures. Configured properly, it provides a fully functional backoffice without custom frontend development.
# apps/admin/admin.py
from django.contrib import admin
from django.utils.html import format_html
from apps.accounts.models import Account
from apps.payments.models import Payment
@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
list_display = ["id", "owner_id", "currency", "balance_display", "status", "synced_at"]
list_filter = ["currency", "status"]
search_fields = ["id", "owner_id"]
readonly_fields = ["id", "owner_id", "currency", "balance_cents", "status",
"created_at", "updated_at", "synced_at"]
ordering = ["-created_at"]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
Read models in the admin are read-only. has_add_permission, has_change_permission and has_delete_permission all return False. The admin interface is for viewing and searching, not for writing data that the Go service owns. Writing through the admin bypasses the Go service's validation, event publishing and audit trail.
For operations that require writes, the admin uses custom actions that call the appropriate Go service API rather than writing to the database directly:
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ["id", "account_id", "amount_display", "status", "created_at"]
list_filter = ["status", "currency"]
search_fields = ["id", "account_id"]
readonly_fields = list_display
actions = ["request_refund"]
@admin.action(description="Request refund via payments service")
def request_refund(self, request, queryset):
import httpx
for payment in queryset.filter(status="settled"):
response = httpx.post(
f"{settings.PAYMENTS_SERVICE_URL}/v1/payments/{payment.id}/refund",
headers={"Authorization": f"Bearer {settings.PAYMENTS_SERVICE_TOKEN}"},
timeout=10.0,
)
if response.status_code == 202:
self.message_user(request, f"Refund requested for payment {payment.id}")
else:
self.message_user(
request,
f"Refund failed for {payment.id}: {response.json().get('message')}",
level="error",
)
Admin actions that need to modify data owned by a Go service make HTTP calls to that service's API. The Go service validates the request, publishes the event, updates its own database, and the Django read model updates when the event arrives. The data flow is consistent regardless of whether the trigger came from an end user, an API call, or an admin action.
The Dockerfile
FROM python:3.12-slim AS base
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
FROM base AS builder
COPY requirements/production.txt .
RUN pip install --prefix=/install -r production.txt
FROM base AS runtime
COPY --from=builder /install /usr/local
COPY . .
RUN addgroup --system django && adduser --system --ingroup django django
USER django
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--timeout", "30", \
"--access-logfile", "-", \
"--error-logfile", "-"]
PYTHONDONTWRITEBYTECODE and PYTHONUNBUFFERED are not optional in containers. Bytecode files create noise in the image layer diff. Unbuffered output means log lines appear immediately rather than after Python's output buffer fills. A non-root user runs the process. The multi-stage build keeps the final image free of build tools.
Gunicorn workers are set to four as a starting point. The correct number is two times the number of available CPU cores plus one. For a container with two vCPUs, that is five. For one vCPU, that is three. Adjust based on your deployment target.
Conclusion
Django in a Go microservices architecture is not a compromise. It is a deliberate tool selection for the problems Django solves better than Go: complex relational data interfaces, rapid iteration on business logic, production-grade admin tooling, and async task processing with built-in monitoring.
The integration holds together when the boundaries are respected. The Django service owns its own database. It populates read models from events, never from direct database reads. It exposes data through APIs, never through shared schemas. Admin actions that write data call Go service APIs rather than writing to read models directly.
Violate any of those boundaries and the Django service becomes a liability: a second writer to data that the Go service is supposed to own, a direct database reader that breaks when the Go service changes its schema, a tight coupling point that makes both services harder to change independently. Respect the boundaries and Django becomes the most productive part of the architecture for the surfaces it is suited for.