I imported a Python file by its path with the usual three-line importlib
recipe, and it blew up inside the standard library’s dataclasses.py with
AttributeError: 'NoneType' object has no attribute '__dict__'. The error
names neither importlib nor sys.modules, and the exact same recipe had
worked on other files for months.
TL;DR — add sys.modules[spec.name] = mod before calling
spec.loader.exec_module(mod). Without it, any module that combines
from __future__ import annotations with a @dataclass crashes at import,
because dataclasses resolves string annotations through
sys.modules.get(cls.__module__), which returns None for an unregistered
module.
The setup
I was writing a test harness for a single-file log analyzer: one .py
script, standard library only, that parses web-server access logs into
@dataclass(slots=True) records. The script is not a package and not on
sys.path, so the harness imported it by file path with the short recipe
that circulates everywhere:
import importlib.util
spec = importlib.util.spec_from_file_location("plugin", "/path/to/plugin.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # crashes here
The script’s first line is from __future__ import annotations, which turns
every annotation in the file into a plain string at runtime. That line is the
whole story.
What I expected
exec_module runs the file’s code with the module’s namespace as globals.
I expected that to be equivalent to a normal import, minus the sys.path
search — the module object exists, its __name__ is set, the code runs
inside it. Registration in sys.modules looked like an optional courtesy
for circular imports and pickling, not something the file’s own top-level
code could depend on.
What actually happens
A minimal file is enough to reproduce it:
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class P:
x: int
Loading that file with the three-line recipe on Python 3.14.4 crashed with a
traceback that points at the decorator, then descends into the standard
library:
File "/path/to/plugin.py", line 4, in <module>
@dataclass
^^^^^^^^^
File "/usr/lib/python3.14/dataclasses.py", line 1055, in _process_class
and _is_type(type, cls, dataclasses, dataclasses.KW_ONLY,
File "/usr/lib/python3.14/dataclasses.py", line 814, in _is_type
ns = sys.modules.get(cls.__module__).__dict__
AttributeError: 'NoneType' object has no attribute '__dict__'. Did you mean: '__dir__'?
Two control experiments pinned it down. The same file with the
from __future__ import annotations line removed loaded fine, unregistered.
And the original file loaded fine once I registered the module first. The
real analyzer script behaved identically to the toy file: unregistered
import crashed at its first dataclass, registered import succeeded.

The fix
One line, placed before exec_module:
import importlib.util
import sys
def import_from_path(module_name, file_path):
spec = importlib.util.spec_from_file_location(module_name, file_path)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod # the line that matters
spec.loader.exec_module(mod)
return mod
plugin = import_from_path("plugin", "/path/to/plugin.py")
print(plugin.P(1)) # P(x=1)
This is in fact what the current official recipe
does — the sys.modules line is there, uncommented and unexplained. The
three-line version without it survives in older answers and in muscle
memory, and it genuinely works for most files, which is what makes the
failure look random. Pick a module_name that cannot collide with a real
installed module, and if the load can fail, remove the entry again in an
except block so a half-executed module does not linger in sys.modules.
Why it works
With from __future__ import annotations, the annotation on x: int is the
string "int", not the type int. The @dataclass decorator still has to
find out whether each annotation is one of its own markers — ClassVar,
InitVar, or KW_ONLY — because those change how a field is treated. With
real objects it can compare identities. With strings it can only match
text, and to guard against KW_ONLY meaning something else in your module,
it looks the identifier up in the defining module’s globals:
# dataclasses.py, _is_type
ns = sys.modules.get(cls.__module__).__dict__
A normal import statement registers the module in sys.modules before
executing its body — that is precisely what makes circular imports
resolvable. importlib.util.module_from_spec does not; registration is left
to the caller. So during exec_module, cls.__module__ is set but
sys.modules.get() returns None, and the .__dict__ access produces an
error with no importlib frame in it.
Without the __future__ import there is no string to resolve, _is_type is
never consulted, and the unregistered recipe works — which is why the recipe
can sit in a codebase for a long time before one loaded file opts into
string annotations and breaks it.
What I did not test
Only Python 3.14.4 on Linux. On 3.14, annotations are lazily evaluated by
default (PEP 649), and my unregistered import of a future-free dataclass
module worked; older versions evaluate annotations eagerly and I did not
rerun the matrix there, though _is_type has resolved strings through
sys.modules for a long line of releases. I also did not test other
consumers of cls.__module__ — typing.get_type_hints, pickling, inspect
— which plausibly fail in their own ways on an unregistered module.
Facts
context: importing a .py file by path with importlib (spec_from_file_location, module_from_spec, exec_module)
problem: module using `from __future__ import annotations` plus @dataclass crashes with AttributeError NoneType __dict__ in dataclasses._is_type
solution: register the module first — sys.modules[spec.name] = mod — then call exec_module
verified_on: 2026-08-17
applies_to: [Python 3.14.4, dataclasses with string annotations, CPython]
does_not_apply_to: [modules without `from __future__ import annotations`, normal `import` statements]