Strict Mode for Protecting Built-in Functions

Proposal: Strict Mode for Protecting Built-in Functions

                        Abstract

This proposal introduces an optional strict mode in Python that prevents the accidental overwriting of built-in functions like len, print, max, etc.
This feature will be opt-in and backward-compatible, allowing developers to choose whether to enforce protection against redefining built-in names.

                        Motivation

Python allows developers to overwrite built-in functions without warnings or errors. While this is sometimes intentional (e.g., mocking in tests), it is often an accidental mistake that leads to unexpected bugs.

For example:

len = 10 # Overwrites the built-in len()
print(len([1, 2, 3])) # TypeError: ‘int’ object is not callable

In many other languages, reserved words and built-in functions cannot be redefined.
While Python values flexibility, an optional safeguard would help avoid accidental errors.

                        Rationale

Instead of enforcing this restriction for everyone (which would break existing code),
we introduce a strict mode that developers can enable when they want added protection.

                        Proposal

Python will introduce a new global flag that prevents overwriting built-in functions when enabled:

:one: sys.strict_builtins

                        A new flag in the sys module:

import sys
sys.strict_builtins = True # Enables protection against overwriting built-ins

If a user tries to overwrite a built-in function while sys.strict_builtins is True, Python will raise an error:
len = 10 # NameError: ‘len’ is a protected built-in function!

By default, sys.strict_builtins = False, ensuring backward compatibility.

:two: strict_mode Module

Alternatively, developers could enable strict mode via a new module:
import strict_mode
strict_mode.enable()

Once activated, attempts to overwrite built-ins would raise an error.

Benefits

:white_check_mark: Prevents accidental mistakes that can lead to hard-to-debug errors.
:white_check_mark: Backward-compatible – default behavior remains unchanged.
:white_check_mark: Explicit opt-in mechanism – developers choose if they want this safeguard.
:white_check_mark: Easier debugging – avoids common beginner and expert mistakes.

                        Backward Compatibility

This proposal does not change Python’s default behavior. Existing code will continue to work unless strict mode is explicitly enabled.

                        Discussion

-Should this protection apply only to functions or also to keywords?
-Should it only warn the user instead of raising an error?
-Should the strict_mode module allow fine-grained control (e.g., blocking only specific built-ins)?

                        Conclusion

This proposal enhances Python’s safety by offering an optional mechanism to prevent unintended overwriting of built-in functions.
It allows Python to remain flexible while giving developers a tool to reduce errors and improve code quality.

Counter-proposal: Configure your editor to colorize all builtins (there aren’t that many of them, especially if you leave out the “obvious” ones like exception types), which gives an even higher level of safety by reporting the issue while you’re still typing.

3 Likes

You can also use linting to assist here, ruff (A) / flake8-builtins protect against builtin and stdlib names in your own code: https://docs.astral.sh/ruff/rules/#flake8-builtins-a

4 Likes

type and filter frequently appear as named parameters, a case in which shadowing a builtin name is actively useful.

Pylint can also flag these usages for you.

Disabling shadowing of builtin names would break library code, so such a change – even if it’s opt in – would be massively breaking. (The interpreter has no reliable way to know the difference between first party and third party code.)

1 Like

A potential advantage that has not been mentioned and that linters don’t help with is optimisation. The compiler could generate more efficient code if it could inline things like len.

To avoid breaking library code I think that this would have to be a per-module opt-in like a __future__ import.

3 Likes

I think that calls for a more specific optimization option than a future import, since this would never become the future behavior. But good point that it would be cool to optimize out the name lookup for builtins.

I don’t know what the feature would look like. Some special list of module names in sys?

That would only work if there could be a guarantee that the builtins themselves aren’t changed.

external_function()
len("spam") # are you sure this is 4?

# ... in some other module ...
def external_function():
    import builtins
    builtins.len = lambda x: 42

(By the way, don’t do this in the REPL. It crashed before getting back to another prompt :slight_smile: It’s amazing how quickly you can break things when you carelessly mess with builtins!)

If you want this as an optimization, without fundamentally breaking a lot of Python, it would have to be something like a directive at the top of the file saying “whatever len is at the start of import, use that exact thing all through this module”. That still wouldn’t be much of an optimization, unless you could ALSO have some flag on the len function itself saying “this function is idempotent and can be run safely on literals as part of constant folding”.

I’m much more inclined to see this as a bug catcher than an optimization, in which case it can be done very effectively by the editor or a linter.

I floated the idea of freezing modules for import speed a little bit ago, but Steve Dower poured some cold water on the idea[1]. It’s a little bit related to this idea, though–if a set of stdlib modules were frozen, they could be marked read-only and more aggressively optimized

One way to implement this is as a runtime flag, e.g. python -X frozen-std=true or something, which is by default off. In that case, it could become the default behavior over the course of a few versions, if the performance improvement was worth it. I suspect it would be worth it for a majority of users who aren’t modifying modules in the stdlib at all.


  1. apparently it doesn’t speed things up much ↩︎

I thought that was the original proposal? If len was read-only then builtins.len = lambda x: 42 should be an error. So Oscar’s suggestion would apply.

Possibly, but Oscar’s post (the one I replied to) described it as a per-module change. And if that restriction only applies on a per-module basis, any external function could have that line in it.

Coincidentally, today I wrote a function where I intentionally shadowed builtins.filter:

def from_filter(self, filter: str, /) -> SomeApiResponse: ...

In my case the API endpoint I’m working with calls itself filter so being able to use the same term is a nice readability bonus.

1 Like

The way I imagined it was you do something like:

import __frozen_names__

Then another module could assign builtins.len = whatever but that won’t affect any call to len in this module. The compiler can translate a call to len into a special bytecode rather than a function call and can avoid all name lookups from either globals or builtins. Just avoiding those name lookups already makes things faster but subsequent optimisations based on the bytecode would also become possible.

3 Likes

I have to wonder if breaking any existing shadowing of these names is actually worth it.

The one I see most likely to break projects, and too disruptive is type especially as the name of a kwarg.

I agree that a frozen names future-like import that signals to the compiler to specialize builtins as if they aren’t replaceable (and can therefore be optimized as such) might have value, but it’s probably worth collecting a list of things not currently possible to optimize at compiling to bytecode and seeing how many of these ideas we could reasonably do if that’s going to be a selling point for it.

3 Likes

an additional use-case is in jupyter notebooks, where linting doesn’t work as strictly.

If you are aware of settings that currently disable the following code (in the context of a jupyter notebook), please let me know :stuck_out_tongue:

match '2':
  case 2:
    print("It's two")
  case str:
    print("It's a string")
#assert str == '2'

(Copilot autocomplete is also extremely unhelpful in this regard :roll_eyes:)

For optimisation purposes, maybe there could be a directive for explicitly freezing selected names, e.g.

from __frozennames__ import int, str, len

Then those functions could be optimised while still allowing you to use type as a parameter name, etc.

1 Like

I suspect that if you follow down this line then you will end up wanting to freeze the module globals rather than just builtins. If you can freeze the names of the functions in the module as well as those imported from elsewhere then you get to the point where you can inline byte code from one function into another.

When the function to function boundary becomes transparent the smaller byte code optimisations stack up significantly. I know there are lots of JIT-like things in CPython now but I don’t know to what extent (if any) they can use things like guards to compensate for not having frozen namespaces so that they can optimise across the function call boundary.

Freezing globals is more complicated because you have to think about how that plays out while the module is still being imported. With builtins it is easier because their “frozen” version is known ahead of time.

It would be easier to import builtins rather than defining strict and non-strict modes.

I tend to override them as much as I can—no built-in function is stopping me. That doesn’t make my code any less strict.

I think the real problem in Python is the lack of const.

Side note: Pycharm warns you when you redefine a built-in.

1 Like