Decorators: Feature Flags

In our fifth installment on decorators, we shift gears a little and look at decorators that are less about runtime wizardry and more about surviving the development process—especially when you’re working on a shared codebase with others.

This is where feature flags come in. They let you wire new logic into the code without actually turning it on immediately. A feature can exist, be tested, be reviewed, and even be deployed—while remaining safely dormant. It will only become active when a specific flag is set. No half-finished code paths, no commenting-out before a release, and no awkward “don’t touch that function” messages in Slack.
Feature flags are particularly useful in collaborative projects. Different people can work on different features in parallel, experimental code can live next to stable code, and enabling or disabling behaviour becomes a configuration choice rather than a code change. In other words: fewer merge conflicts, cleaner commits, and a much calmer development workflow.
Feature flags: Environment matters...
Let’s say you want a function’s behaviour to depend on an environment variable. A common case: once you reach the final testing or release stage, you might want all “test-only” helper functions to be effectively disabled—without ripping them out of the code or commenting out half the file.
That’s exactly the kind of job feature flags excel at: the code stays in place, but whether it runs is controlled externally.
The first step is therefore not Python at all: we define the environment variables outside the code (in your shell, CI pipeline, Docker config, or .env file). Once that switch exists, the decorator can simply read it and decide whether the function is allowed to do anything. Let's set some environment variables in the bash
Then the goal is simple: only functions that are marked as production logic should actually run. Anything tagged as “testing”, “experimental”, or “dev-only” should quietly opt out when the environment says so.
So we start where the leverage is highest: the decorator. That’s the gatekeeper. It decides, at call time, whether the wrapped function gets to execute—or whether it returns early (or you could opt to raise an exception instead) because the current environment isn’t allowed to run it.
Once the decorator is in place, we can look at what else we need around it: how we read environment variables, how we define defaults, and how we keep the naming consistent so the whole thing doesn’t turn into a flag spaghetti bowl.
As we can see, the function is decorated with two bits of metadata: the flag name and a default value to fall back on if that flag isn’t present in the environment.
At call time, the decorator acts like a gate: it checks whether the feature is enabled by looking for the flag in the process environment. If the flag exists (and is set to an “enabled” value), the wrapped function runs; otherwise it stays inactive unless we also put some default flag in.
To keep that check clean and consistent, it helps to funnel the environment parsing into a single helper—something like “given a flag name and a default, tell me if it’s enabled.” For that we use the following function.
For this we lean on getenv from the os module and define a few sensible interpretations of what “true” means. Environment variables are strings, after all, and people tend to express yes in a variety of ways.
So the logic is straightforward: read the flag from the environment, normalise it, check whether it matches one of the accepted “true” values, and return the result. If the flag isn’t set at all, we simply fall back to the provided default.
Now we create two simple simulation functions
and call them
With that setup in place, the outcome becomes easy to read: given our chosen flags, the first function runs, while the second one stays disabled. That’s exactly what we want when switching to the production variant.
Nothing about the functions themselves had to change. The difference in behaviour comes entirely from the environment configuration, which makes it very clear which paths are meant to be active and which ones are intentionally switched off.
Nesting
This can, of course, be nested quite naturally. Imagine you’ve got a brand-new version of the software behind a “new version” flag, but the non-debug implementation of process_data in that new version isn’t ready yet.
Now you’re dealing with layers of rollout:
one flag to decide whether you’re on the new or old version, and
another flag to decide whether the production (non-debug) path is allowed to run.
So you add another flag and treat the decorators like stacked gatekeepers: the function only runs if it passes all the relevant checks. This is a nice way to ship incrementally—new version on for selected environments, production logic still protected until it’s actually ready. In this case we add the environment variables
and modify our functions decorations to
In that configuration, neither of the two process_data functions would be available. The non-debugging one is too old, the newer one only exists in its testing version. To avoid this type of behaviour it would be good to instead also include a fallback.
Additional fallback function
As we mentioned above, we might want a fallback when the “preferred” function isn’t allowed to run. For example, you might be in an environment where the newest process_data function is disabled because it depends on a system that simply isn’t available there. In that case, instead of doing nothing (or raising), we want the call to route to an alternative implementation. In this case you would have a flag indicating whether the new system needed is in place.
if the flag is enabled → run the new implementation
if it’s disabled → call the fallback implementation instead if one is available
That keeps behaviour consistent across environments. The decorator gets one extra ingredient: a fallback function (or callable) it can invoke when the flag is not set.
So the change is minimal: if the flag is disabled and a fallback is provided, we just call the fallback instead. The decorator stops being a strict “on/off switch” and becomes a small decision point.
The same pattern can be adapted nicely for debugging. You can flip the logic so when the normal function is called but a debug flag is set, it automatically falls back to a more verbose debug-friendly implementation. In that setup, you typically wouldn’t decorate (or directly call) the debug versions at all—they’re only reached when the normal function is not allowed.
Once you combine this with nesting, you can support quite a range of scenarios: different environments, staged rollouts, partial feature availability, or platform-specific behaviour. At that point, a simple set of flags can effectively act as a configuration map for how your application should behave under many different conditions.
Final thoughts
Of course, you don’t want to take this too far. Feature flags are meant to clarify behaviour, not turn it into a puzzle.
If the flag setup becomes so layered that nobody can confidently say which code path is running, you’ve basically swapped one kind of risk for another. A small, well-named set of flags with clear ownership and a plan to remove them later is usually the sweet spot.



Comments