Build a Custom Ecommerce Website with Django

Blog / Web Development · July 15, 2024 · Updated June 10, 2026 · 14 min read
Build a Custom Ecommerce Website with Django

Build a Custom Ecommerce Website with Django

Should you build a custom ecommerce site with Django? Choose custom Django when your store has logic that off-the-shelf platforms fight you on: complex pricing, B2B quoting, subscriptions tied to usage, deep ERP/CRM integrations, a large or fast-changing catalogue, or a headless front end you fully control. If you sell a handful of standard products and want to launch this week, a hosted platform like Shopify will be faster and cheaper to start. Django shines once you outgrow that box and need to own the data model, the checkout, and the roadmap without per-transaction platform fees or app-store rate limits.

Django is a mature, batteries-included Python framework with a strong security record, a built-in admin, an ORM that handles large catalogues well, and a rich ecosystem (django-oscar, Saleor, Wagtail, Django REST Framework). On Django 5.2 LTS and Python 3.12+, you get a modern, supported stack you can run for years. At MicroPyramid we have shipped Django ecommerce and product platforms for 12+ years across 50+ projects, so this guide reflects how we actually build these systems today.

This is the answer-first version: when custom Django makes sense, the architecture and core models, a real Stripe checkout, the packages worth using, and how to handle search, security, SEO and deployment.

When custom Django ecommerce makes sense (vs Shopify, WooCommerce, Saleor)

Start with the decision, not the framework. Most stores do not need custom code. You should reach for custom Django when one or more of these is true:

  • Non-standard commerce logic — usage-based or tiered pricing, B2B quotes and approvals, marketplaces with multiple sellers, rentals, bookings, or bundles that platform "apps" can't express cleanly.
  • Deep integrations — your store must be the source of truth alongside an ERP, a custom WMS, accounting, or a CRM, with real-time sync rather than nightly CSV exports.
  • Catalogue scale or complexity — millions of SKUs, rich variant matrices, or per-customer catalogues and pricing.
  • You want to own the stack — no per-transaction platform fee on top of payment processing, no app subscriptions stacking up, and full control of data and roadmap.
  • A bespoke front end — a headless storefront (Next.js, SvelteKit, native mobile) talking to your own API.

If none of these apply, a hosted platform is the rational choice. Be honest about total cost of ownership: custom software has higher upfront engineering effort and you own maintenance, but you avoid recurring platform/app fees and gain unlimited extensibility. Here is how the common options compare.

Comparison: build options at a glance

Factor Custom Django Shopify WooCommerce Saleor (headless)
Control over data model & logic Full Limited (platform schema) Moderate (PHP/WP plugins) High (GraphQL API, you own front end)
Time to first launch Slower Fastest Fast Moderate
Recurring cost of ownership No platform/app fees; you maintain it Monthly plan + per-transaction + paid apps Hosting + paid plugins Self-host cost or Saleor Cloud
Extensibility / custom logic Unlimited Constrained by app ecosystem Plugin-dependent, can get fragile High, API-first
Headless / multi-channel Native (build your own API) Hydrogen / Storefront API Possible but awkward Built for it (GraphQL-first)
Best fit Complex/at-scale stores, custom logic Simple to mid stores, fast launch WordPress-centric content+commerce API-first teams wanting a Python core

A reasonable middle path: start with django-oscar (a domain-driven Django ecommerce framework) if you want a head start on cart, catalogue and order pipelines, or Saleor if you specifically want a GraphQL, headless-first core. Build fully from scratch only when your domain is unusual enough that a framework would get in the way. We cover the from-scratch path below because it teaches the model that everything else is built on.

Architecture and the core data model

Every ecommerce system, custom or not, rests on the same handful of entities. Get these right and the rest of the build is mechanical. The core objects are:

  • Product and Variant — a Product is the marketing concept ("Merino Crew Sweater"); a Variant (or SKU) is the buyable thing with its own price and stock ("Merino Crew Sweater / Navy / M").
  • Cart (basket) and CartItem — a mutable pre-purchase container, usually tied to a session for guests and a user when logged in.
  • Order and OrderItem — an immutable snapshot at purchase time. Critically, copy the price and product details onto the order; never recompute totals from live product prices later.
  • Payment — a record of the money movement, linked to your processor (Stripe PaymentIntent, charge ID, status).
  • Address, ShippingMethod, Inventory/StockRecord as the catalogue and fulfilment grow.

Key principle: snapshot at purchase

The single most important modelling rule in ecommerce: an Order is a financial record, not a live join. When a customer checks out, freeze the unit price, quantity, tax and product name onto OrderItem rows. If you later change a product's price or delete it, historical orders, invoices and reports must stay correct. This one decision prevents a whole class of accounting bugs.

Product and Variant models

Here is a clean, modern model layer on Django 5.x. Note the use of DecimalField for money (never FloatField — floats cause rounding errors), indexes on lookup fields, and a separate variant for stock and price.

# store/models.py - Django 5.x, Python 3.12+
# Product / Variant: always use DecimalField for money, never FloatField.
from decimal import Decimal
from django.db import models
from django.utils.text import slugify


class Category(models.Model):
    name = models.CharField(max_length=120)
    slug = models.SlugField(max_length=140, unique=True, db_index=True)

    class Meta:
        verbose_name_plural = "categories"

    def __str__(self) -> str:
        return self.name


class Product(models.Model):
    """The marketing concept. Buyable units live on Variant."""
    category = models.ForeignKey(
        Category, on_delete=models.PROTECT, related_name="products"
    )
    name = models.CharField(max_length=200)
    slug = models.SlugField(max_length=220, unique=True, db_index=True)
    description = models.TextField(blank=True)
    is_active = models.BooleanField(default=True, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        return self.name


class Variant(models.Model):
    """A specific buyable SKU with its own price and stock."""
    product = models.ForeignKey(
        Product, on_delete=models.CASCADE, related_name="variants"
    )
    sku = models.CharField(max_length=64, unique=True, db_index=True)
    name = models.CharField(max_length=120, help_text="e.g. 'Navy / M'")
    price = models.DecimalField(max_digits=10, decimal_places=2)
    quantity = models.PositiveIntegerField(default=0)
    is_active = models.BooleanField(default=True)

    @property
    def in_stock(self) -> bool:
        return self.is_active and self.quantity > 0

    def __str__(self) -> str:
        return f"{self.product.name} - {self.name}"

Cart, Order and Payment models

The cart is mutable and session-scoped; the order is the frozen snapshot. Keep payment state on its own model so you can reconcile against your processor.

# store/models.py (continued) - Cart, Order, Payment
from django.conf import settings
from django.db import models


class Cart(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True,
        on_delete=models.CASCADE, related_name="carts",
    )
    session_key = models.CharField(max_length=40, blank=True, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True)

    @property
    def total(self):
        return sum((i.line_total for i in self.items.all()), Decimal("0.00"))


class CartItem(models.Model):
    cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items")
    variant = models.ForeignKey("Variant", on_delete=models.PROTECT)
    quantity = models.PositiveIntegerField(default=1)

    class Meta:
        unique_together = ("cart", "variant")

    @property
    def line_total(self) -> Decimal:
        return self.variant.price * self.quantity


class Order(models.Model):
    """Immutable financial record. Snapshot prices onto OrderItem."""
    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        PAID = "paid", "Paid"
        SHIPPED = "shipped", "Shipped"
        CANCELLED = "cancelled", "Cancelled"

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
    )
    email = models.EmailField()
    status = models.CharField(
        max_length=20, choices=Status.choices, default=Status.PENDING, db_index=True
    )
    total = models.DecimalField(max_digits=12, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)


class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
    variant = models.ForeignKey("Variant", on_delete=models.PROTECT)
    # Snapshot fields - frozen at purchase, never recomputed from live data.
    product_name = models.CharField(max_length=200)
    unit_price = models.DecimalField(max_digits=10, decimal_places=2)
    quantity = models.PositiveIntegerField()


class Payment(models.Model):
    order = models.OneToOneField(
        Order, on_delete=models.CASCADE, related_name="payment"
    )
    provider = models.CharField(max_length=40, default="stripe")
    intent_id = models.CharField(max_length=255, db_index=True)
    status = models.CharField(max_length=40, default="requires_payment")
    amount = models.DecimalField(max_digits=12, decimal_places=2)

The build: checkout, payments and inventory

The riskiest part of any store is checkout. The golden rule for PCI compliance and security: card data must never touch your server. Use a processor's hosted, tokenised flow — Stripe Checkout or Stripe Elements/Payment Element — so the browser sends card details directly to the processor and your backend only ever sees a token or a PaymentIntent id. This keeps you in the lightest PCI scope (SAQ A) instead of handling raw card numbers.

Stripe checkout flow (recommended pattern)

  1. Customer clicks "Pay". Your Django view creates a PaymentIntent (or a Checkout Session) server-side, with the amount computed on the server from the cart — never trust an amount sent by the client.
  2. The browser confirms payment directly with Stripe using the client secret and Stripe's JS. Card data goes browser → Stripe, never through Django.
  3. Stripe sends a webhook (payment_intent.succeeded or checkout.session.completed) to your backend. This is the source of truth for "paid". Verify the webhook signature, then create/confirm the Order and decrement stock inside a database transaction.

Treat the webhook, not the browser redirect, as the moment of truth — users close tabs and lose connections. Always reconcile from the webhook.

Inventory and overselling

Decrement stock when payment is confirmed, inside transaction.atomic(), and guard against overselling with select_for_update() (row lock) or a conditional update. For high-contention drops/flash sales, a conditional UPDATE ... WHERE quantity >= n is simpler and avoids long-held locks. Decide your policy explicitly: reserve at "add to cart", at "begin checkout", or at "payment confirmed". Most stores reserve at payment confirmation to avoid abandoned-cart stock leakage.

# store/views.py - create a Stripe PaymentIntent (amount computed server-side)
import stripe
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST

from .models import Cart

stripe.api_key = settings.STRIPE_SECRET_KEY


@require_POST
def create_payment_intent(request):
    cart = get_active_cart(request)
    if not cart.items.exists():
        return JsonResponse({"error": "Cart is empty"}, status=400)

    # Always compute the amount on the server. Stripe wants the smallest
    # currency unit (e.g. cents), so multiply by 100 and cast to int.
    amount = int(cart.total * 100)

    intent = stripe.PaymentIntent.create(
        amount=amount,
        currency="usd",
        automatic_payment_methods={"enabled": True},
        metadata={"cart_id": str(cart.id)},
    )
    return JsonResponse({"clientSecret": intent.client_secret})
# store/webhooks.py - the Stripe webhook is the source of truth for "paid"
import stripe
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from .models import Cart, Order, OrderItem, Payment, Variant


@csrf_exempt
@require_POST
def stripe_webhook(request):
    payload = request.body
    sig = request.META.get("HTTP_STRIPE_SIGNATURE", "")
    try:
        event = stripe.Webhook.construct_event(
            payload, sig, settings.STRIPE_WEBHOOK_SECRET
        )
    except (ValueError, stripe.error.SignatureVerificationError):
        return HttpResponseBadRequest("Invalid webhook signature")

    if event["type"] == "payment_intent.succeeded":
        fulfil_order(event["data"]["object"])

    return HttpResponse(status=200)


def fulfil_order(intent):
    """Create the Order and decrement stock atomically.

    Idempotent: a duplicate webhook for the same intent is ignored.
    """
    cart_id = intent["metadata"]["cart_id"]
    with transaction.atomic():
        if Payment.objects.filter(intent_id=intent["id"], status="paid").exists():
            return  # already processed

        cart = Cart.objects.select_for_update().get(id=cart_id)
        order = Order.objects.create(
            user=cart.user,
            email=intent.get("receipt_email") or "",
            status=Order.Status.PAID,
            total=cart.total,
        )
        for item in cart.items.select_related("variant"):
            # Lock the row and guard against overselling.
            variant = Variant.objects.select_for_update().get(pk=item.variant_id)
            if variant.quantity < item.quantity:
                raise ValueError(f"Out of stock: {variant.sku}")
            variant.quantity -= item.quantity
            variant.save(update_fields=["quantity"])

            OrderItem.objects.create(
                order=order,
                variant=variant,
                product_name=variant.product.name,
                unit_price=variant.price,   # snapshot
                quantity=item.quantity,
            )

        Payment.objects.create(
            order=order, intent_id=intent["id"],
            status="paid", amount=cart.total,
        )
        cart.items.all().delete()

Search, admin and content

Search. For a small catalogue, PostgreSQL full-text search (SearchVector, SearchRank, plus a GIN index and pg_trgm for fuzzy matching) is free, fast enough, and one less service to run. For large catalogues, faceted filtering, typo tolerance and instant search-as-you-type, move to a dedicated engine — Elasticsearch/OpenSearch, Typesense, or Meilisearch. A common pattern is Postgres FTS at launch, then a search service once filtering and relevance become product-critical.

Admin. Django's built-in admin is a genuine competitive advantage for ecommerce: catalogue management, order lookup, refunds workflows and customer support tooling come almost for free. Customise ModelAdmin with list_display, list_filter, search_fields, inlines for variants/line items, and admin actions for bulk operations (mark shipped, export). Many teams ship the entire back office on Django admin and only build custom screens where the workflow demands it.

Content. Marketing pages, landing pages, lookbooks and blog content pair well with Wagtail, a Django-native CMS, sitting alongside your commerce app so marketers edit content without touching code.

Packages worth knowing

You rarely build everything from zero. The strongest pieces of the Django commerce ecosystem:

  • django-oscar — a domain-driven ecommerce framework with catalogue, basket, offers/vouchers, and order pipelines, designed so any class can be overridden to fit your domain. Great when you want commerce primitives but still want a Django monolith you control. See our guides on creating an ecommerce shop with Django Oscar and the Oscar checkout flow.
  • Saleor — a high-performance, GraphQL-first, headless commerce platform with a Python/Django heritage. Choose it when you want an API-first core and your own decoupled storefront.
  • Wagtail — Django-native CMS for marketing/content pages alongside commerce.
  • Django REST Framework (DRF) — the standard for building a JSON API to power a headless storefront or native mobile app. See building a RESTful web service in Django with DRF.
  • Stripe Python SDK — payments, subscriptions, refunds and webhooks.
  • Celery + Redis — background jobs for emails, invoices, webhook processing and search reindexing.
  • django-allauth — accounts, social login and email verification.
  • Customising Oscar — when you do use it, our walkthrough on customising Oscar models, views and URLs shows the override pattern.

Performance and security

Ecommerce handles money and personal data, so security is non-negotiable and performance directly affects conversion.

Security essentials

  • PCI scope — use Stripe's hosted/tokenised flows so raw card data never hits your servers; stay in SAQ A scope.
  • HTTPS everywhere — enable HSTS, SECURE_SSL_REDIRECT, secure cookies (SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE).
  • Use Django's built-in protections — CSRF tokens, the ORM (which parameterises queries against SQL injection), and template auto-escaping against XSS. Run manage.py check --deploy before go-live.
  • Rate limiting — throttle login, checkout and API endpoints (django-ratelimit or DRF throttling) to blunt brute-force and card-testing attacks.
  • Secrets — keep keys in environment variables, never in source control; rotate processor keys if exposed.
  • Webhook signature verification — always verify Stripe webhook signatures so attackers can't forge "paid" events.

Performance essentials

  • Database — index foreign keys and filter/sort columns; eliminate N+1 queries with select_related/prefetch_related; paginate large listings.
  • Caching — cache category and product pages, and expensive aggregations, with Redis/Memcached; use a CDN (CloudFront, Cloudflare) for static and media assets.
  • Async work — push emails, PDF invoices, and reindexing to Celery so requests stay fast.
  • Core Web Vitals — page speed is both an SEO ranking factor and a direct lever on conversion.

SEO for ecommerce

Search drives a large share of ecommerce demand, and Django gives you full control of the HTML — which is exactly what good ecommerce SEO needs.

  • Clean, stable URLs — readable category and product slugs; redirect (301) when slugs change so you don't lose ranking.
  • Server-rendered HTML — Django renders complete pages by default, which is ideal for crawlers. (If you go headless, ensure the storefront is server-rendered or pre-rendered.)
  • Structured data — emit Product, Offer, AggregateRating and BreadcrumbList JSON-LD so listings can show rich results (price, availability, ratings).
  • Canonical tags & faceted navigation — set canonical URLs and use noindex/canonical rules so filtered/sorted variants don't create duplicate-content sprawl.
  • Unique titles, meta descriptions and H1s per category and product; avoid manufacturer boilerplate.
  • XML sitemaps — Django's sitemaps framework generates product/category sitemaps automatically.
  • Performance — fast pages (Core Web Vitals) rank and convert better.

For a broader view of building search-friendly Django sites, see our web development and Python development services.

Deployment

A standard, dependable production stack for Django ecommerce:

  • App server — Gunicorn or uWSGI behind Nginx (TLS termination, static file serving, gzip/brotli).
  • Database — managed PostgreSQL (RDS / Cloud SQL) with automated backups and read replicas as you scale.
  • Cache & queue — Redis for caching and as the Celery broker.
  • Static & media — S3 (or equivalent) + a CDN for product images.
  • Containers — Docker images deployed to ECS/EKS, Kubernetes, or a PaaS; keep config in environment variables (12-factor).
  • Observability — error tracking (Sentry), logging, uptime and payment-failure alerts.
  • CI/CD — automated tests, migrations and zero-downtime deploys.

For step-by-step deployment walkthroughs, see our guide on deploying Django on AWS Elastic Beanstalk.

Where MicroPyramid fits

We have built and maintained custom Django ecommerce and product platforms for 12+ years across 50+ projects — from catalogue-heavy stores and B2B portals to headless storefronts and Saleor/Oscar customisations. We increasingly embed AI into commerce (search relevance, product recommendations, support agents, merchandising) so the store does more with less. If you're weighing custom Django against an off-the-shelf platform, our Django development and web development teams can help you make the call and ship it.

Frequently Asked Questions

Is Django good for ecommerce?

Yes. Django is well suited to ecommerce because of its mature ORM (which handles large catalogues and complex queries), a strong security track record, a powerful built-in admin for catalogue and order management, and a rich ecosystem (django-oscar, Saleor, Wagtail, DRF). On Django 5.2 LTS and Python 3.12+ it's a modern, supported stack that scales from MVP to high-traffic stores. It's the right tool when you need custom logic or integrations rather than a cookie-cutter shop.

Django vs Shopify for a custom store — which should I choose?

Choose Shopify when you want the fastest possible launch for a fairly standard catalogue and are comfortable with monthly plans, per-transaction fees and an app ecosystem. Choose custom Django when you have non-standard commerce logic (B2B pricing, subscriptions, marketplaces), need deep real-time integrations, want to own your data and roadmap, or are building a bespoke/headless front end. The trade-off is higher upfront engineering for full control and no platform/app fees over time.

How do I handle payments securely in Django?

Never let card data touch your servers. Use a processor like Stripe with a hosted or tokenised flow (Stripe Checkout or Elements/Payment Element): the browser sends card details directly to Stripe and your backend only handles a token or PaymentIntent. Compute the amount on the server (never trust the client), verify webhook signatures, and treat the payment_intent.succeeded webhook — not the browser redirect — as the source of truth for "paid". This keeps you in the lightest PCI scope (SAQ A).

Can Django ecommerce scale?

Yes — Django powers very large, high-traffic applications. Scaling comes from sound engineering rather than the framework: index your database and remove N+1 queries, cache pages and aggregations with Redis, serve assets via a CDN, push slow work to Celery, run multiple app server workers behind a load balancer, and add PostgreSQL read replicas. django-oscar is used in production by stores with tens of millions of products, which is good evidence the stack scales.

Should I build from scratch or use django-oscar or Saleor?

Use django-oscar when you want a Django monolith with ready-made commerce primitives (catalogue, basket, offers, order pipelines) that you can override class-by-class. Use Saleor when you want a GraphQL, headless-first core with a decoupled storefront. Build fully from scratch only when your domain is unusual enough that a framework would get in the way — and even then, the core models (Product, Variant, Cart, Order, Payment) are the same. Most teams start from a framework and customise.

How long does it take to build a custom Django ecommerce site?

It depends on scope: a focused MVP (catalogue, cart, Stripe checkout, basic admin) is a matter of weeks, while a feature-rich platform with B2B logic, ERP integrations, headless storefront and advanced search is a multi-month effort. Starting from django-oscar or Saleor shortens the timeline meaningfully versus from-scratch. Using AI-assisted development, we now deliver and iterate on these builds noticeably faster than traditional timelines.

Share this article