LESSON 71 · DEEPER PYTHON27 min

Use Enums for Clear Python States

Represent a small workflow with Enum members and convert untrusted text at its boundary.

Before you start: Conditions · Functions · Classes and Objects

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Use Enums for Clear Python States

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    Imagine a background job that has finished its work and should move from running to done. The program does not crash. It does not log an exception. It simply never enters the finishing branch. The cause is one extra letter in a state string. Strings are useful at the boundary of a program because forms, files, and APIs often send text. Inside a workflow, though, accepting arbitrary text makes every typo look like one more possible state. Today we will run that bug, inspect why a simple comparison misses it, and replace free-form internal strings with members of a Python Enum. We will still have to accept text from outside, but we will convert it once and

  2. 00:43

    reject unknown values immediately. Then we will make a transition rule and test both legal and illegal transitions. You can follow along in the PythonLessonLab console for this lesson and use its checked task afterward. The goal is not to build an entire task queue. It is to make one small, finite state model that tells us when input is bad and when a job has reached its end. The output says the job is not ready, even though whoever assigned the state intended running. The comparison is doing exactly what we asked: two different strings are not equal. The problem is that an unrecognized spelling was allowed to travel through the

  3. 01:23

    program without a check. Printing repr gives us the exact characters that reached the condition, including a typo or unexpected whitespace that an ordinary status display might hide. We could fix this spelling and move on, but the next consumer might type another version, such as uppercase RUNNING, or accidentally introduce a space. Repeating lowercasing and spelling checks in every function would spread the input rule everywhere. It also becomes tempting to use an else branch as a default state, which can be dangerous: an unknown value might be treated as a queued job and started again.

  4. 02:03

    We need a clear boundary between external text and internal state. Once text crosses that boundary, internal functions should receive a known member from a declared set, not whatever string happened to arrive. The first input is valid after we remove surrounding whitespace and lowercase it. The Enum constructor finds the declared value running and returns JobState.RUNNING. The second input still contains the extra letter, so it cannot match any declared value and raises ValueError. That is a better failure location: the error appears where external input enters our state model, rather than much later when a transition quietly fails.

  5. 02:45

    Notice the difference between a member's name and value. RUNNING is the Python member name; the lowercase text running is the value we chose for interchange. Those values could be stored in a file or sent to an API, so changing them later may require a migration. The enum class does not magically decide business rules for us. It only defines the allowed vocabulary. We still have to say which transitions are legal. We also should not catch ValueError here and silently substitute QUEUED. That would erase evidence of bad input. The caller can display a validation message or reject a bad record explicitly.

  6. 03:26

    The two printed lines show the legal path through this tiny workflow. Repeated references to JobState.RUNNING refer to the same enum member, so an identity check is a precise way to express the rule. The function returns members, not plain strings, which means its caller can keep using the same small vocabulary. The final raise is intentional. If a job is already done, it has no next state in this model. Returning QUEUED there would quietly restart completed work; returning DONE without comment could hide a duplicate transition request. A ValueError forces the caller to handle the invalid operation.

  7. 04:07

    In larger systems you might use a transition table, persist status in a database, and consider concurrency. Those design choices do not change the core idea here: declare the valid states and reject transitions that are not in the contract. Keep conversion from external strings separate from this internal function. The function is easier to read because it does not have to handle uppercase spellings, whitespace, and typos on every call. This verification has three distinct parts. The first assertion confirms that a mixed- case input with spaces becomes the declared RUNNING member. The next two assertions check both legal transitions.

  8. 04:49

    Then we deliberately try two invalid inputs: a misspelling and an empty string. Both must raise. Finally, the done state must reject another advance call. If any expected failure does not happen, our assertions raise instead of printing the success message. This is stronger than looking at one happy-path printout, though it is still a small test set, not a proof about every future workflow rule. In the browser lesson, complete parse_state and can_finish yourself. The hidden checks will include declared states, unknown text, and a plain string passed where an enum member is expected. After that, try adding a CANCELLED member and decide whether a cancelled job can be

  9. 05:29

    advanced. Write the rule before changing the code. The useful habit is to make invalid states and transitions visible at the boundary, so a typo cannot quietly become a business decision elsewhere in the program.

Understand the concept

A string such as 'running' is easy to pass around, but a typo can silently choose the wrong branch. An Enum groups the finite states a workflow allows. Each member has a stable name and value, and repeated references to JobState.RUNNING denote the same member. This makes a transition rule easier to inspect than a collection of unrelated string comparisons. The enum is useful when the set is small and known in advance; it is not a substitute for a database or a full workflow engine.

External data still arrives as text. Convert it once at the input boundary with JobState(text.strip().lower()). That call either returns a declared member or raises ValueError for an unknown value. After conversion, internal functions can accept JobState instead of guessing whether they received 'running', 'RUNNING', or a misspelling. Do not silently turn an unknown state into QUEUED: that would hide corrupt input and could start a job twice.

Enum members are singletons, so `state is JobState.RUNNING` expresses a precise identity check. The `.name` attribute is the Python member name, while `.value` is the value chosen for interchange. Changing serialized values later can break stored data; choose them deliberately. For a small transition, an explicit if chain is readable. If the workflow grows, put allowed transitions in a mapping and reject any missing pair.

The task defines three job states and two boundary functions. `parse_state` trims and lowercases a string, then returns a member or raises ValueError. `can_finish` returns True only for the RUNNING member. Hidden checks test valid inputs, mixed case, unknown text, and a string passed where a member is expected. Run the broken string example before writing the fix, then use the guide to see why converting at the boundary is more reliable than repeating text comparisons everywhere.

  • An Enum declares a finite set of named members
  • Convert text to members at the input boundary
  • Unknown values should raise rather than silently default
  • Identity checks make state rules explicit

See it step by step

Read the code, predict the output, then compare it with the result.

01. Compare declared members

PYTHON
from enum import Enum
class Light(Enum):
    RED = "red"
    GREEN = "green"
print(Light.RED.name)
print(Light.RED.value)
print(Light.RED is Light.RED)
EXPECTED OUTPUT
RED
red
True

A member has a name and a chosen value, and repeated access returns the same member.

02. Convert input at the boundary

PYTHON
from enum import Enum
class State(Enum):
    NEW = "new"
    READY = "ready"
for text in (" READY ", "unknown"):
    try:
        print(State(text.strip().lower()).name)
    except ValueError:
        print("invalid state")
EXPECTED OUTPUT
READY
invalid state

Conversion is explicit; unknown text is not mistaken for a real state.

03. Write a small transition rule

PYTHON
from enum import Enum
class State(Enum):
    NEW = "new"
    READY = "ready"
    SENT = "sent"
def next_state(state):
    if state is State.NEW:
        return State.READY
    if state is State.READY:
        return State.SENT
    raise ValueError("no next state")
print(next_state(State.NEW).name)
print(next_state(State.READY).name)
EXPECTED OUTPUT
READY
SENT

A completed state is rejected instead of being restarted through a default branch.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Reproduce the state-string typo

A workflow can appear to have only three states, yet a free-form string accepts unlimited misspellings. In this run, the producer writes 'runnning' while the consumer checks 'running'. Python sees two different strings and silently takes the wrong branch. No exception names the mistake, which makes the later behavior harder to diagnose.

Printing the raw state helps locate the typo, but changing this one branch is not a durable fix. The next component might spell the state differently again. Decide on one finite set of values, convert external text when it enters the program, and let internal functions work with members of that set.

PYTHON
state = "runnning"
can_finish = state == "running"
print(can_finish)
print(state)
EXPECTED OUTPUT
False
runnning
Follow the reasoning
  1. Run the comparison and notice that the job cannot finish.
  2. Compare the raw value with the intended spelling before changing the workflow.
02 / 03

Convert external text to an Enum member

An Enum declares every state with a name and value. Converting a cleaned input string with JobState(text.strip().lower()) returns exactly one of those members. An unknown string raises ValueError immediately, close to the source of the bad data. Do not replace that failure with a default QUEUED state; that could restart work that was never meant to restart.

Use `.value` for the chosen serialized text and `.name` when you want the declared Python label. Those are different interfaces. Here the parsed member can be passed to `can_finish`, where identity with JobState.RUNNING is a simple rule. This code prints the valid member first, then catches and names the bad input.

PYTHON
from enum import Enum

class JobState(Enum):
    QUEUED = "queued"
    RUNNING = "running"
    DONE = "done"

def parse_state(text):
    return JobState(text.strip().lower())

print(parse_state(" RUNNING ").name)
try:
    parse_state("runnning")
except ValueError:
    print("unknown state")
EXPECTED OUTPUT
RUNNING
unknown state
Follow the reasoning
  1. Trace the strip and lowercase operation before Enum conversion.
  2. Check that the typo is rejected rather than becoming a fourth implicit state.
03 / 03

Verify a legal transition and a rejected one

A state type helps only when transitions are also explicit. This function advances QUEUED to RUNNING and RUNNING to DONE. A DONE job has no next state, so the function raises ValueError instead of looping back to the start. Passing raw text is a caller error; parse it at the boundary before invoking this function.

Test both normal transitions and the terminal boundary. A larger workflow might use a transition table, but a small if chain is easier to teach and audit. Enum identity is appropriate because each member is one stable object. In the exercise, implement parsing and a narrower can_finish rule, then try a misspelling and a completed job yourself.

PYTHON
from enum import Enum

class JobState(Enum):
    QUEUED = "queued"
    RUNNING = "running"
    DONE = "done"

def next_state(state):
    if state is JobState.QUEUED:
        return JobState.RUNNING
    if state is JobState.RUNNING:
        return JobState.DONE
    raise ValueError("job already finished")

print(next_state(JobState.QUEUED).name)
print(next_state(JobState.RUNNING).name)
try:
    next_state(JobState.DONE)
except ValueError as error:
    print(error)
EXPECTED OUTPUT
RUNNING
DONE
job already finished
Follow the reasoning
  1. Run both legal transitions and read the resulting member names.
  2. Call the terminal state and confirm it raises instead of restarting.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnComplete parse_state(text) and can_finish(state). Parse declared values case-insensitively after trimming; unknown text must raise ValueError. Only JobState.RUNNING may finish. The prepared calls should print RUNNING and True.
Need a hint?

Use JobState(text.strip().lower()) inside parse_state; the Enum constructor already raises ValueError for unknown values. For can_finish, compare identity with JobState.RUNNING, not the string 'running'.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.What happens when State('missing') has no declared value?

    02.Where should text from a form be converted to an enum?

    03.What does JobState.RUNNING.name provide?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    A typo silently skips a string branch

    COMMON MISTAKE
    if state == 'runnning':
        finish()
    FIX
    if state is JobState.RUNNING:
        finish()

    What happens: The branch never runs for a correctly spelled running state.
    Normalize external text to an enum, then compare members.

    A default hides an unknown state

    COMMON MISTAKE
    return states.get(text, JobState.QUEUED)
    FIX
    return JobState(text.strip().lower())

    What happens: A misspelling may accidentally start a new job.
    Let unknown values raise ValueError so the caller can correct the input.

    Browse this lesson’s problem fixes

    Sources and further reading