Hello again. In the previous lesson, you used type annotations to turn informal assumptions into explicit contracts: collections state their contents, optional values state when absence is valid, and functions declare what they accept and return.
There is still an important gap in a signature such as status: str. It says that a status is text, but it does not say which text is legitimate. An incident API should not quietly accumulate spellings such as "in progress", "in_progress", "investigating", and "Investigation". In this lesson, you will represent finite incident vocabularies with Python Enum types, including severity and lifecycle status. This gives the domain one named source of truth before request models, database mappings, and FastAPI endpoints arrive later.
From arbitrary strings to a domain vocabulary
A string is a flexible transport format, but it is a weak domain type. Consider an implementation based entirely on strings:
def needs_immediate_attention(severity: str) -> bool:
return severity in {"high", "critical"}
The function works for valid inputs, but nothing in its signature stops a caller from passing "urgent", "HIGH", or a typo such as "critcal". A reviewer must search the repository to discover the accepted values. A static type checker only knows that all of them are strings.
An enumeration, or enum, models a finite set of named values. Rather than treating severity as any string, we can make it a distinct type:
from enum import Enum, unique
@unique
class IncidentSeverity(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
IncidentSeverity is a type, and IncidentSeverity.HIGH is one of its members. Its two parts have deliberately different audiences:
| Part | Example | Purpose |
|---|---|---|
| Member name | IncidentSeverity.HIGH | A readable, refactor-friendly Python symbol used in domain code |
| Member value | "high" | A stable representation suitable for JSON, configuration, and later persistence |
The convention is to write enum member names in UPPER_CASE, like constants. The values are lower-case strings because they are likely to cross a system boundary. A client payload, Terraform-like configuration value, or database row benefits from a predictable machine-readable representation.
The @unique decorator is particularly appropriate for domain vocabularies. Python normally permits aliases: two member names can have the same underlying value. For incident severity, that would hide a modelling error rather than express useful meaning. @unique fails when the class is created if duplicate values exist.
@unique
class IncidentSeverity(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "high" # ValueError during import: duplicate value
A duplicate such as this is caught when the application imports the module, rather than becoming a confusing behavior in production.
Python's enum - Start Building Enumerations
Watch “Python's enum - Start Building Enumerations” from Real Python for a compact visual introduction to why enums are useful and how members are declared.
Watch the motivation for enums, especially the idea of a restricted collection of related constants. Then watch class based enums to reinforce that enum members are immutable members of a dedicated type, not merely specially named variables.
Model severity and status as separate types
Create src/incident_api/incident_enums.py. Keeping the module small and focused makes these foundational domain types easy to import and review.
from enum import Enum, unique
@unique
class IncidentSeverity(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
@unique
class IncidentStatus(str, Enum):
NEW = "new"
ACKNOWLEDGED = "acknowledged"
INVESTIGATING = "investigating"
MITIGATED = "mitigated"
RESOLVED = "resolved"
CLOSED = "closed"
The particular vocabulary is a domain decision. An organization may choose OPEN instead of NEW, or distinguish MONITORING from MITIGATED. What matters structurally is that each concept has one canonical enum and that changes to the vocabulary are visible in one place.
It is worth keeping IncidentSeverity and IncidentStatus separate even though both use strings underneath. They answer different questions:
- Severity describes impact or urgency.
- Status describes the current point in an incident’s lifecycle.
A function that requires severity should declare that fact:
from incident_api.incident_enums import IncidentSeverity
def needs_immediate_attention(severity: IncidentSeverity) -> bool:
return severity in {
IncidentSeverity.HIGH,
IncidentSeverity.CRITICAL,
}
Now a caller documents its intent clearly:
needs_immediate_attention(IncidentSeverity.CRITICAL)
A type checker can flag this incorrect call:
needs_immediate_attention("critical")
Python itself still does not enforce annotations at every function call. If this code is executed without static checking, it may happen to work because IncidentSeverity is also a string-like enum. The type annotation nevertheless matters: tools such as mypy can distinguish the intended domain type from an arbitrary string, and the explicit constructor shown later provides runtime validation at an input boundary.
Why inherit from both str and Enum?
This declaration:
class IncidentSeverity(str, Enum):
creates enum members that also behave as strings. That is useful for an API-focused application because the stable values are textual: "low", "high", and so on.
For example:
severity = IncidentSeverity.HIGH
print(severity.value) # high
print(isinstance(severity, str)) # True
Using str, Enum is a practical compatibility pattern for JSON-oriented applications. Python 3.11 also provides StrEnum, but str, Enum works on older supported Python versions and makes the string behavior explicit.
Internally, though, prefer comparing members with members:
if severity is IncidentSeverity.CRITICAL:
notify_primary_on_call()
The is comparison is appropriate because an enum member is a singleton object. More importantly, it keeps business logic expressed in domain terms rather than in raw transport values.
Do not rely on ordering for severity:
if severity >= IncidentSeverity.HIGH:
...
A regular enum has no natural ordering, and severity order is not as universal as it first appears. If the business needs severity ranking later, define it explicitly through a mapping or a method, with tests. For now, membership checks make the policy visible and avoid smuggling a numerical scale into the model.
Accessing members, names, and values correctly
There are three related but distinct ways to access an enum member:
from incident_api.incident_enums import IncidentSeverity
critical = IncidentSeverity.CRITICAL # by member name in source code
high = IncidentSeverity("high") # by external value
medium = IncidentSeverity["MEDIUM"] # by member name received dynamically
These forms serve different purposes.
| Form | Input expected | Typical use | Failure for invalid input |
|---|---|---|---|
IncidentSeverity.HIGH | Known Python member name | Normal domain code | Caught while writing/running code |
IncidentSeverity("high") | Member value | JSON, environment/configuration, database data | ValueError |
IncidentSeverity["HIGH"] | Member name | Internal tooling that receives symbolic names | KeyError |
For an HTTP API, "high" is the relevant representation, so construction by value is generally the useful boundary operation:
raw_severity = "high"
severity = IncidentSeverity(raw_severity)
assert severity is IncidentSeverity.HIGH
An unknown value is rejected rather than silently accepted:
IncidentSeverity("urgent")
This raises ValueError. That behavior is valuable: it prevents a non-existent severity from entering the domain model.
Enum members expose .name and .value:
severity = IncidentSeverity.CRITICAL
print(severity.name) # CRITICAL
print(severity.value) # critical
Use .value for an explicit external representation. Avoid using .name as a public API or persistence contract merely because it is available. Renaming CRITICAL to SEV_1 during an internal refactor would change .name, while the deliberately chosen value "critical" can remain stable.
You can also iterate over an enum in definition order:
allowed_severity_values = [
severity.value
for severity in IncidentSeverity
]
print(allowed_severity_values)
# ['low', 'medium', 'high', 'critical']
That makes the enum useful as a single source for a configuration UI, a command-line choice list, or generated documentation. It also avoids maintaining a second hard-coded list elsewhere in the codebase.

The dropdown is a useful future benefit, but the reason to introduce the enum now is deeper: the domain code itself gains a precise vocabulary. API documentation should reflect that vocabulary, not become its only definition.
Watch “Enums in Python are SO useful” by Carberra for a concise example of converting a string received from an API payload into its corresponding enum member.
Watch value conversion. Focus on the distinction between the string that arrives in JSON and the enum member that the application uses after conversion.
Validate external strings once, then use enum members internally
A useful design boundary is:
- External systems provide primitive values such as strings.
- Boundary code converts and validates them.
- Domain functions receive enum members, not unchecked strings.
For a small non-HTTP example, write a conversion helper:
from incident_api.incident_enums import IncidentSeverity
def parse_incident_severity(raw_severity: str) -> IncidentSeverity:
try:
return IncidentSeverity(raw_severity)
except ValueError as error:
raise ValueError(
f"Unsupported incident severity: {raw_severity!r}"
) from error
The explicit error gives a maintainer useful context while preserving the original exception as the cause.
Use it at the boundary:
severity = parse_incident_severity("critical")
if severity is IncidentSeverity.CRITICAL:
print("Page the primary on-call engineer")
Avoid silently normalizing input unless that behavior is a deliberate, documented contract:
# Usually too permissive without an explicit compatibility requirement
IncidentSeverity(raw_severity.strip().lower())
Automatically accepting " Critical ", "CRITICAL", and "critical" may sound friendly, but it introduces multiple client representations for the same value. Strict canonical values reduce ambiguity in logs, dashboards, test fixtures, and client integrations. If backward compatibility later requires aliases or normalization, treat that as an explicit boundary policy with tests.
Enums also clarify what they do not validate. IncidentStatus.RESOLVED is a valid member, but that does not mean every incident may move to RESOLVED from its current status. Membership in the vocabulary and validity of a state transition are separate rules. You will model transitions explicitly in a later module.
A small, typed domain policy
With the enums in place, revisit the typed helper style from the previous lesson:
from incident_api.incident_enums import IncidentSeverity, IncidentStatus
def needs_immediate_attention(severity: IncidentSeverity) -> bool:
return severity in {
IncidentSeverity.HIGH,
IncidentSeverity.CRITICAL,
}
def can_be_visible_to_customers(status: IncidentStatus) -> bool:
return status in {
IncidentStatus.MITIGATED,
IncidentStatus.RESOLVED,
IncidentStatus.CLOSED,
}
These functions now tell a reviewer more than their earlier str-based counterparts:
- The input is constrained to a known domain vocabulary.
- A severity cannot accidentally be passed where a status is expected without a type-checking error.
- Each allowed case is readable and searchable.
- Adding a new member invites a review of policies that may need updating.
This is the same advantage gained by giving infrastructure components narrow contracts: the interface exposes exactly the meaningful set of choices, rather than accepting arbitrary data and hoping downstream code interprets it consistently.
Implementation checkpoint
Add incident_enums.py with the two enums and the two policy functions. Then confirm that the module imports and that iteration yields the expected values:
uv run python -c "from incident_api.incident_enums import IncidentSeverity; print([member.value for member in IncidentSeverity])"
Expected output:
['low', 'medium', 'high', 'critical']
Also inspect these design choices before considering the change complete:
- Each member value is unique because
@uniqueis present. - Member names are Python-oriented constants; values are stable lower-case external identifiers.
- Functions that consume severity or status accept the enum type, not
str. - Conversion from untrusted or external strings happens before domain logic uses the value.
- You have not added transition rules merely because a status enum now exists.
Key takeaways
Enums represent a fixed, meaningful domain vocabulary more precisely than unrestricted strings.
- Define incident severity and status as separate
Enumtypes. - Use
str, Enumwhen the member values are stable strings intended for API-oriented boundaries. - Use uppercase member names such as
IncidentSeverity.CRITICALand lower-case values such as"critical". - Use
@uniqueto prevent accidental aliases in a strict domain vocabulary. - Convert external strings with
IncidentSeverity(raw_value)and handle itsValueErrorat the boundary. - Keep domain logic expressed in enum members, while using
.valuewhen an explicit external string is needed. - An enum establishes valid states; it does not yet establish valid transitions between statuses.
Next, you will use dataclasses to model internal incident-domain values cleanly, without coupling those values to either future API payloads or database tables.
Can't find a good explanation? Sign up and we'll make it for you
Sign up