On this page

Stop Over-Engineering Django State Machines (Use This Instead of django-fsm)

Learn how to build a Django state machine without django-fsm using django-lifecycle hooks. A simple, scalable approach for real-world workflows and CI/CD systems.

If you're trying to implement a state machine in Django without using heavy libraries like django-fsm, there’s a simpler approach you might already be using without realizing it.
Using django-lifecycle hooks, you can build a declarative state machine directly inside your models without introducing new abstractions or DSLs.
Most real-world systems — including deployment pipelines, ticketing systems, and async workflows — don’t need complex state machine frameworks. They need one simple rule:
When a field changes from X to Y, trigger a specific action.
This article shows how to achieve that using django-lifecycle.


Why Most Django Apps Need a State Machine

You may or may not realise it, but every application with non-trivial business logic eventually needs a state machine.

  • An order goes from pending to paid to shipped
  • A document goes from draft to reviewed to published

The standard advice is to use libraries like:

  • django-fsm
  • django-viewflow
  • python-statemachine

These work, but they introduce:

  • Additional dependencies
  • New abstractions (DSLs)
  • More complexity than needed

In most cases, all you really need is:
When this field changes from X to Y, do Z.

How django-lifecycle Works (Architecture Overview)

Architecture diagram showing how django-lifecycle hooks act as a state machine by triggering actions based on model field changes

he Pattern: Hooks as State Transitions

The core idea is simple.
Instead of defining transitions like this:

class OrderStateMachine:
    transitions = [
        {'trigger': 'pay', 'source': 'pending', 'dest': 'paid'},
        {'trigger': 'ship', 'source': 'paid', 'dest': 'shipped'},
    ]

You write:

from django_lifecycle import LifecycleModel, hook, AFTER_UPDATE, AFTER_CREATE

class Order(LifecycleModel):
    status = models.CharField(max_length=20, default="pending")

    @hook(AFTER_UPDATE, when="status", was="pending", is_now="paid")
    def on_payment_received(self):
        send_receipt_email.delay(self.id)

    @hook(AFTER_UPDATE, when="status", was="paid", is_now="shipped")
    def on_order_shipped(self):
        send_tracking_email.delay(self.id)

The hook decorator declares that after the model is saved, if the status changes from one value to another, a specific method runs. That is a state transition.
You get the behavior of a state machine without introducing a separate library.


What You Get for Free

  • Transition guards: hooks only fire on exact transitions
  • Side effects: logic is defined inside each hook method
  • Audit trail support: self.initial_value("status") gives previous state
  • Implicit control: invalid transitions simply don’t trigger hooks

What You Don’t Get (and Often Don’t Need)

  • Visual state diagrams
  • Built-in transition validation before save
  • .can_transition() helpers

These can still be implemented if required using additional hooks or model methods.


Real-World Examples

Deployment Pipeline (CI/CD Use Case)

A CI/CD deployment runs asynchronously. When it completes, you update metrics and invalidate caches. When it fails, you only invalidate caches.

class Deployment(LifecycleModel):
    status = models.CharField(max_length=20)  # running, completed, failed
    project = models.ForeignKey("Project", on_delete=models.CASCADE)

    @hook(AFTER_SAVE, when="status", was="running", is_now="completed")
    def on_deployment_completed(self):
        recalculate_project_metrics.delay(str(self.project.uuid))
        recalculate_team_metrics.delay(self.project.team_id)
        invalidate_project_cache.delay(
            project_id=str(self.project.uuid),
            team_id=self.project.team_id,
        )

    @hook(AFTER_SAVE, when="status", was="running", is_now="failed")
    def on_deployment_failed(self):
        invalidate_project_cache.delay(
            project_id=str(self.project.uuid),
            team_id=self.project.team_id,
        )

Support Ticket with Audit Trail

Support tickets move across multiple states, and each transition creates an audit entry.

class Ticket(LifecycleModel):
    status = models.CharField(max_length=20)
    title = models.CharField(max_length=200)

    @hook(AFTER_CREATE)
    def on_ticket_created(self):
        TicketHistory.objects.create(
            ticket=self,
            action_type=TicketHistory.ActionType.CREATED,
            previous_status="",
            new_status=self.status,
            performed_by=None,
            comment=f"Ticket created: {self.title}",
        )

    @hook(AFTER_UPDATE, when="status", has_changed=True)
    def on_status_changed(self):
        previous_status = self.initial_value("status")

        if self.status == self.Status.CLOSED:
            action_type = TicketHistory.ActionType.RESOLVED
            performed_by = getattr(self, "_resolving_user", None)
            comment = getattr(self, "_resolve_comment", "") or "Ticket resolved"

        elif self.status == self.Status.OPEN and previous_status == self.Status.CLOSED:
            action_type = TicketHistory.ActionType.REOPENED
            comment = "Ticket reopened by customer"

        elif self.status == self.Status.SNOOZED:
            action_type = TicketHistory.ActionType.SNOOZED
            performed_by = self.snoozed_by
            comment = self.snooze_reason

        elif self.status == self.Status.OPEN and previous_status == self.Status.SNOOZED:
            action_type = TicketHistory.ActionType.UNSNOOZED
            comment = "Ticket marked as active"

        TicketHistory.objects.create(
            ticket=self,
            action_type=action_type,
            previous_status=previous_status,
            new_status=self.status,
            performed_by=performed_by,
            comment=comment,
        )

Invoice Processing Workflow

class Invoice(LifecycleModel):
    status = models.CharField(max_length=20)
    final_pdf = models.FileField(blank=True)

    @hook(AFTER_UPDATE, when="status", is_now=InvoiceStatus.APPROVED)
    def on_approved_generate_pdf(self):
        pdf_bytes = PDFService().generate_invoice_pdf(self)

        receipts = self.receipts.filter(file__isnull=False)
        if receipts.exists():
            merged = merge_pdfs(
                pdf_bytes,
                *[r.file.read() for r in receipts],
            )
            self.final_pdf.save("invoice.pdf", ContentFile(merged), save=False)
        else:
            self.final_pdf.save("invoice.pdf", ContentFile(pdf_bytes), save=False)

        self.save(update_fields=["final_pdf"])

Watching Non-Status Fields

@hook(AFTER_UPDATE, when="final_pdf", has_changed=True)
def on_pdf_ready_notify(self):
    if not self.final_pdf:
        return
    invoice_pdf_ready.send(
        sender=self.__class__,
        invoice=self,
    )

Hooks work on any field type, not just status fields.


django-lifecycle vs django-fsm

Best Practices

  • Name hook methods based on transitions, not actions
  • Use initial_value() for tracking previous state
  • Keep hooks small and focused
  • Offload heavy operations to async workers

FAQ

Can you build a state machine in Django without django-fsm?

Yes, django-lifecycle allows you to define state transitions using model hooks without additional libraries.

What is django-lifecycle used for?

It replaces Django signals and enables conditional logic when model fields change.

When should you use a state machine in Django?

When your application has defined workflows such as orders, deployments, approvals, or ticket systems.


Read More on KubeBlogs

If you're exploring DevOps, Kubernetes, and cloud infrastructure, these guides will help you go deeper:

These articles cover Kubernetes networking, AWS storage optimization, and CI/CD infrastructure — useful when scaling beyond local development environments.


Conclusion

Most Django applications do not need a heavy state machine library like django-fsm. In real-world systems, especially in CI/CD pipelines and asynchronous workflows, state transitions are simple and event-driven.
Using django-lifecycle hooks, you can implement a clean and maintainable state machine directly at the model level. This approach keeps your logic simple, avoids unnecessary dependencies, and scales well with real-world backend systems.