How to Build an ERP From Scratch With Django: Data Model, Workflow, and Architecture

August 24, 2026

Odoo and ERPNext solve the ERP problem for most companies. If your processes fit within 20% of a standard module’s configuration options, customizing an existing platform is almost always cheaper than building one — we’ve written about implementing ERPNext and about why ERP projects fail for exactly that reason.

But there’s a real minority of cases where a custom build wins: your core process is your competitive advantage and doesn’t map to any standard module (a commodity trading desk with FIFO lot allocation, a depot operation with weighbridge integration, a manufacturer with a proprietary yield-costing model), or you need to embed the ERP inside a product you’re selling rather than run it as internal tooling. This guide is for that case: how to actually structure an ERP-style system in Django, not "should you."

Everything below is architecture and patterns, not a full working system — the goal is to save you from the mistakes that only show up after your third or fourth business document type.

1. App structure: organize by business domain, not by technical layer

The most common early mistake is a single core app with every model in it. Split by domain from day one — it costs nothing early and saves a rewrite later:

flowchart TD
    A["parties<br/>Customer, Supplier, Contact"] --> E["sales<br/>Quotation, SalesOrder, Invoice"]
    A --> F["purchasing<br/>PurchaseOrder, Receipt, Bill"]
    B["inventory<br/>Item, Warehouse, StockLedgerEntry"] --> E
    B --> F
    C["accounting<br/>Account, GLEntry, Period"] --> E
    C --> F
    D["core<br/>SubmittableDocument, NamingSeries, AuditLog"] --> E
    D --> F
    D --> B
    D --> C

core holds only shared abstractions — no business logic. parties unifies customers and suppliers into one underlying model (most real-world entities are both, at different times), which avoids the classic problem of duplicated address/contact logic across Sales and Purchasing.

2. The submittable-document pattern

This is the single most valuable pattern to steal from mature ERPs like ERPNext, and Django doesn’t give it to you for free — you have to build it. The core idea: transactional documents (Sales Order, Invoice, Payment) move through draft → submitted → cancelled, and only submitted documents are allowed to trigger side effects (stock movement, GL postings). Draft documents are freely editable; submitted documents are effectively immutable.

# core/models.py
from django.db import models
from django.core.exceptions import ValidationError

class DocStatus(models.IntegerChoices):
    DRAFT = 0, "Draft"
    SUBMITTED = 1, "Submitted"
    CANCELLED = 2, "Cancelled"

class SubmittableDocument(models.Model):
    docstatus = models.IntegerField(choices=DocStatus.choices, default=DocStatus.DRAFT)
    submitted_at = models.DateTimeField(null=True, blank=True)
    submitted_by = models.ForeignKey(
        "auth.User", null=True, blank=True, on_delete=models.PROTECT,
        related_name="+",
    )

    class Meta:
        abstract = True

    def save(self, *args, **kwargs):
        if self.pk:
            original = type(self).objects.get(pk=self.pk)
            if original.docstatus == DocStatus.SUBMITTED and self.docstatus == DocStatus.SUBMITTED:
                raise ValidationError("Submitted documents cannot be edited. Cancel and amend instead.")
        super().save(*args, **kwargs)

    def submit(self, user):
        if self.docstatus != DocStatus.DRAFT:
            raise ValidationError("Only draft documents can be submitted.")
        self.on_submit()
        self.docstatus = DocStatus.SUBMITTED
        self.submitted_by = user
        self.save()

    def on_submit(self):
        """Override in subclasses to post GL entries, move stock, etc."""
        raise NotImplementedError

Every transactional model (SalesOrder, PurchaseInvoice, Payment) inherits SubmittableDocument and implements on_submit(). This single pattern is what gives you an audit trail instead of a spreadsheet with delete permissions — and it’s the piece teams most often skip when they build ERP-adjacent systems in Django, right up until an auditor asks why a posted invoice’s amount changed.

3. Double-entry accounting as a first-class model, not an afterthought

If your custom system touches money at all, model the general ledger as an immutable, append-only table from the start. Retrofitting double-entry accounting onto a system that was tracking balances as mutable integer fields is a rewrite, not a migration.

erDiagram
    ACCOUNT ||--o{ GL_ENTRY : posts_to
    PARTY ||--o{ SALES_INVOICE : billed_to
    SALES_INVOICE ||--|{ SALES_INVOICE_ITEM : contains
    SALES_INVOICE ||--o{ GL_ENTRY : generates
    PAYMENT ||--o{ GL_ENTRY : generates
    PAYMENT ||--o{ SALES_INVOICE : settles
# accounting/models.py
class GLEntry(models.Model):
    account = models.ForeignKey("Account", on_delete=models.PROTECT)
    debit = models.DecimalField(max_digits=18, decimal_places=2, default=0)
    credit = models.DecimalField(max_digits=18, decimal_places=2, default=0)
    posting_date = models.DateField()
    voucher_type = models.CharField(max_length=50)   # "Sales Invoice", "Payment", ...
    voucher_id = models.PositiveIntegerField()
    party = models.ForeignKey("parties.Party", null=True, on_delete=models.PROTECT)

    class Meta:
        indexes = [
            models.Index(fields=["account", "posting_date"]),
            models.Index(fields=["voucher_type", "voucher_id"]),
        ]

Every GLEntry row is created once, inside on_submit(), and never updated — corrections are reversing entries plus a new correct entry, exactly as a paper ledger would require. This makes reconciliation and audit trivially traceable and makes "why doesn’t my balance sheet balance" a solvable query instead of a forensic investigation.

4. Numbering, sequencing, and the concurrency trap

Every document needs a human-readable ID (SO-2026-00042). The naive approach — read the last number, increment, save — is a race condition under concurrent load: two Sales Orders submitted in the same second can grab the same number. Use a dedicated sequence row with select_for_update():

def get_next_number(series_key: str) -> str:
    with transaction.atomic():
        series, _ = NamingSeries.objects.select_for_update().get_or_create(
            key=series_key, defaults={"current": 0}
        )
        series.current += 1
        series.save()
        return f"{series_key}-{series.current:05d}"

This is a small piece of code that causes an outsized amount of production pain when skipped — duplicate invoice numbers are the kind of bug that surfaces during an audit, not during testing.

5. Workflow and approval states

Naming series and docstatus cover the submit/cancel lifecycle; approval chains (Draft → Pending Manager Approval → Approved) are a separate concern and don’t belong hardcoded into your models. A finite-state-machine library (django-fsm or a hand-rolled equivalent) keeps transition rules and permission checks in one place instead of scattered across views:

from django_fsm import FSMField, transition

class PurchaseRequisition(SubmittableDocument):
    state = FSMField(default="draft")

    @transition(field=state, source="draft", target="pending_approval")
    def request_approval(self):
        pass

    @transition(field=state, source="pending_approval", target="approved",
                permission=lambda instance, user: user.has_perm("purchasing.approve_requisition"))
    def approve(self):
        pass

Keep the FSM for human approval workflows and docstatus for the system-level draft/submit/cancel lifecycle — conflating the two is a common source of confusing, hard-to-test state.

6. Posting side effects asynchronously

on_submit() for a Sales Invoice might need to post GL entries, update stock, recompute customer outstanding balances, and trigger a notification. Doing all of that synchronously inside the HTTP request is what makes "Submit" buttons feel slow under load — the same problem covered in our ERP performance guide for Odoo and ERPNext applies just as much to something you build yourself.

Split it: the parts that must be atomic with the submit transaction (GL entries, stock ledger — because a user should never see "Submitted" on a document that didn’t actually post) stay synchronous inside the database transaction; the parts that are best-effort (email notification, PDF generation, analytics recompute) go to Celery:

def on_submit(self):
    with transaction.atomic():
        self._post_gl_entries()
        self._update_stock_ledger()
    send_invoice_notification.delay(self.id)   # Celery task, fire-and-forget

7. Permissions: role-based first, row-level only where it earns its cost

Django’s built-in Group/Permission system handles "can this user submit Sales Orders" perfectly well. Row-level permission ("can this user see only their own territory’s Sales Orders") is a much heavier feature — django-guardian or a custom queryset filter on your base manager both work, but every row-level rule you add is a query-plan cost that compounds at scale, per the same N+1 and per-row-evaluation traps that show up in Odoo/ERPNext customizations. Default to role-based; add row-level scoping only for the specific models where it’s a real business requirement, not everywhere "to be safe."

8. API layer with Django REST Framework

If the ERP needs a mobile app, a portal, or third-party integrations (and most eventually do), expose the document lifecycle through DRF rather than only server-rendered views — it forces a cleaner separation between the state machine and the presentation layer:

class SalesOrderViewSet(viewsets.ModelViewSet):
    queryset = SalesOrder.objects.all()
    serializer_class = SalesOrderSerializer

    @action(detail=True, methods=["post"])
    def submit(self, request, pk=None):
        order = self.get_object()
        order.submit(user=request.user)
        return Response(SalesOrderSerializer(order).data)

Nested line items (SalesOrderItem) serialize well with DRF’s writable nested serializers, but validate totals server-side regardless of what the client sends — never trust a client-computed line total or tax amount.

9. What you’re signing up for by not buying

Building this yourself means you now own: migrations forever, security patching, report-building tooling that ERPNext/Odoo ship out of the box, and every edge case in tax and rounding logic that a mature platform has already hit and fixed. The honest reason to build is that your core workflow is genuinely novel — not that "Odoo felt too heavy" or "we wanted full control." If the real motivation is customization pain on an existing platform, revisit why ERP projects fail before committing to a from-scratch build; a surprising number of "we need custom" conversations turn out to be configuration problems.

Checklist before you write your first model

  1. Have you separated apps by business domain, not technical layer?
  2. Does every transactional model have a draft/submit/cancel lifecycle, and are submitted documents actually immutable?
  3. Is the ledger (if you have one) append-only, with corrections as reversing entries?
  4. Is document numbering safe under concurrent submits (select_for_update, not read-increment-save)?
  5. Are approval workflows separated from the submit/cancel lifecycle?
  6. Are slow, non-critical side effects pushed to Celery instead of blocking the submit request?
  7. Is permission logic role-based by default, with row-level rules added only where justified?
  8. Have you re-confirmed that no existing platform (Odoo, ERPNext) already does 80% of this?

Sources:

Ready to talk about your project?

Share goals and constraints. We'll assemble architects and engineers to move fast with you.

Get in touch