Before generating bytecode, the Python compiler walks the syntax tree and decides the scope of every identifier. It must determine whether a name is local, global, nonlocal, a parameter, an import, a free variable, or a namespace. The Python symtable module exposes these compiler symbol tables for static analysis, educational tools, linters, documentation generators, and investigations of closures.
This guide shows how to create a table with symtable(), traverse functions and classes, inspect symbols, and recognize free variables, globals, annotations, comprehensions, and type parameters. It complements our articles about Python bytecode with dis, introspection with inspect, descriptors, singledispatch, and tracebacks.
The role of a symbol table
An abstract syntax tree describes functions, assignments, and calls, but the compiler must still resolve the meaning of each name in its block. Symbol tables are generated after the AST and before bytecode.
They determine, for example, that a name assigned inside a function is local unless a global or nonlocal declaration changes that classification. They also identify free variables captured by closures.
Create a symbol table
symtable.symtable() accepts source code, a filename used in diagnostics, and a compilation mode.
import symtable
source = """
rate = 0.1
def total(value):
return value * (1 + rate)
"""
table = symtable.symtable(
source,
"example.py",
"exec",
)
print(table.get_name())
print(table.get_type())Modes follow compile(): exec for modules and blocks, eval for expressions, and single for an interactive statement.
Symbol table types
get_type() returns a SymbolTableType member. Core types represent modules, functions, and classes. Modern Python also has scopes for annotations, type aliases, type parameters, and type variables.
from symtable import SymbolTableType
if table.get_type() is SymbolTableType.MODULE:
print("Module table")The official symtable documentation recommends the enum rather than hard-coded strings because textual values may change.
Module identifiers
get_identifiers() returns the names recognized in the block.
print(list(table.get_identifiers()))
# ['rate', 'total']get_symbols() returns Symbol objects with detailed flags.
for symbol in table.get_symbols():
print(
symbol.get_name(),
symbol.is_local(),
symbol.is_global(),
symbol.is_namespace(),
)At module level, assigned names are generally both local to the module and global to the program.
Look up one name
lookup() retrieves the symbol entry for an identifier.
symbol = table.lookup("total")
print(symbol.is_assigned())
print(symbol.is_namespace())A name introduced by a function or class definition is a namespace. One name may be associated with more than one namespace if source code binds it repeatedly.
Traverse child tables
Functions, classes, and other nested scopes appear in get_children().
for child in table.get_children():
print(
child.get_name(),
child.get_type(),
child.get_lineno(),
)has_children() reports nested namespaces, while is_nested() identifies a nested function or class block.
Analyze a function
Function tables provide dedicated methods for parameters, locals, globals, nonlocals, and free variables.
function = table.get_children()[0]
print(function.get_parameters())
print(function.get_locals())
print(function.get_globals())
print(function.get_nonlocals())
print(function.get_frees())In the example, value is a parameter and local. rate is global because the function reads it without assigning it locally.
Implicit globals
Consider reading a name with no local assignment.
source = """
settings = {}
def get_settings():
return settings
"""In the function table, settings is global. This does not prove that the name will exist at runtime; the table classifies how a lookup is compiled.
Explicit global declarations
is_declared_global() distinguishes an explicit global statement from an implicit global lookup.
source = """
counter = 0
def increment():
global counter
counter += 1
"""
table = symtable.symtable(source, "counter.py", "exec")
function = table.get_children()[0]
symbol = function.lookup("counter")
print(symbol.is_global())
print(symbol.is_declared_global())A linter can use this distinction to flag mutations of global state.
nonlocal and closures
nonlocal permits an inner function to modify a variable in an enclosing function.
source = """
def make_counter():
value = 0
def increment():
nonlocal value
value += 1
return value
return increment
"""The inner table classifies value as nonlocal and free.
root = symtable.symtable(source, "closure.py", "exec")
outer = root.get_children()[0]
inner = outer.get_children()[0]
print(inner.get_nonlocals())
print(inner.get_frees())This explains why the compiler creates cells accessed by bytecode operations such as LOAD_DEREF.
Symbol flags
A Symbol exposes several predicates:
is_referenced(): used in its block;is_assigned(): assigned in its block;is_parameter(): function parameter;is_imported(): created by an import;is_local(),is_global(), andis_nonlocal();is_free(): resolved from an enclosing scope;is_annotated(): carries an annotation;is_namespace(): introduces a namespace.
Combine flags rather than assuming every classification is mutually exclusive.
Imports
is_imported() identifies names created by import statements.
source = """
import json
from pathlib import Path
"""
table = symtable.symtable(source, "imports.py", "exec")
for name in table.get_identifiers():
symbol = table.lookup(name)
print(name, symbol.is_imported())This can support dependency analyzers. Dynamic imports through importlib or __import__() do not appear as ordinary static import bindings.
Annotations
is_annotated() reports whether a name has an annotation.
source = """
quantity: int
price: float = 10.0
"""
table = symtable.symtable(source, "types.py", "exec")
for name in table.get_identifiers():
print(name, table.lookup(name).is_annotated())An annotation and an assigned value are separate facts. A name may be annotated without receiving a runtime value on that line.
Type parameters in modern Python
Python 3.12 introduced type-parameter syntax and related compiler scopes. Python 3.14 adds is_type_parameter().
source = """
def first[T](items: list[T]) -> T:
return items[0]
"""
table = symtable.symtable(source, "generics.py", "exec")The parser must support the syntax. Tools should detect the runtime version and use SymbolTableType.TYPE_PARAMETERS instead of matching internal names.
Classes and methods
Class tables inherit from SymbolTable. The get_methods() helper lists method-like functions declared directly in the body, but it was deprecated in Python 3.14 and is scheduled for removal in Python 3.16.
New tools should traverse get_children(), select function tables, and account for type-parameter scopes that may appear between a class and its methods.
Class names and free variables
Python 3.14 adds is_free_class() for a class-scoped name that is free from the perspective of a method.
source = """
def outer():
x = 1
class C:
x = 2
def method(self):
return x
"""The method returns the enclosing function’s x, not the attribute assigned in the class body. Class blocks do not behave like ordinary enclosing function scopes.
Comprehensions
Python 3.14 adds is_comp_iter() for iteration variables and is_comp_cell() for cells related to inlined comprehensions.
source = """
def squares(values):
return [value * value for value in values]
"""The exact table shape depends on release optimizations. Use public flags instead of relying on names of compiler-generated blocks.
Optimized local storage
is_optimized() indicates whether locals in a block can use optimized storage. Functions typically use fast locals, while modules use namespace mappings.
This flag helps explain why mutating the dictionary returned by locals() has different behavior in module and optimized function scopes.
Table IDs and source lines
get_id() returns a table identifier, while get_lineno() reports the first line of the represented block.
for child in table.get_children():
print(child.get_id(), child.get_lineno())Do not persist get_id() as a stable key across processes or compilations. For reports, combine filename, table type, name, and line.
Command-line usage
Since Python 3.13, the module can be executed as a script.
python -m symtable program.pyWithout files, it reads standard input. The output is useful for exploration, while applications should use the API for structured data.
symtable versus ast
The official AST documentation describes syntax nodes and lets tools locate assignments, calls, and expressions. symtable adds the compiler’s scope classification.
A capable linter often uses both: AST for statement context and the symbol table to understand which namespace a name belongs to.
symtable versus inspect
symtable works with source code without executing the program. inspect examines live objects after import or execution. Static analysis reduces risk for unknown source, although parsers still need limits for very large or adversarial inputs.
Example symbol report
def describe(table, path=()):
current = path + (table.get_name(),)
for symbol in table.get_symbols():
yield {
"scope": ".".join(current),
"name": symbol.get_name(),
"local": symbol.is_local(),
"global": symbol.is_global(),
"nonlocal": symbol.is_nonlocal(),
"free": symbol.is_free(),
"parameter": symbol.is_parameter(),
"imported": symbol.is_imported(),
}
for child in table.get_children():
yield from describe(child, current)The report can be serialized as JSON or displayed in an educational interface.
Limitations
The module does not reveal runtime types, values, executed branches, or dynamic imports. It also follows the compiler rules of the running Python version. Source using newer syntax may raise SyntaxError.
Whole-project analysis must additionally handle multiple files, packages, import graphs, stubs, generated source, and type-checker configuration.
Common mistakes
- Assuming a global name is guaranteed to exist at runtime.
- Treating all symbol flags as mutually exclusive.
- Comparing type strings instead of enum members.
- Depending on deprecated
get_methods(). - Ignoring annotation and type-parameter scopes.
- Assuming class blocks close over names like functions.
- Executing code when static analysis is sufficient.
- Persisting internal table IDs as stable identifiers.
Best practices
- Use
SymbolTableType. - Traverse every child table.
- Combine symtable with AST analysis.
- Record Python version and filename.
- Test globals, nonlocals, closures, and comprehensions.
- Account for modern typing scopes.
- Avoid deprecated helpers.
- Limit the size and complexity of untrusted source.
Conclusion
The Python symtable module opens a window into the compilation phase that resolves identifiers and scopes. It distinguishes parameters, locals, globals, nonlocals, imports, namespaces, annotations, and free variables before bytecode generation.
This information supports linters, educational tools, documentation, and closure analysis. By combining compiler symbol tables with AST data and accounting for new typing and comprehension scopes, a tool can understand the meaning of names without executing the program or reimplementing complex scope rules.







