The symtable module exposes the symbol tables produced by Python’s compiler before bytecode generation. It reveals which names belong to each scope, which ones are parameters, imports, locals, globals, nonlocals, free variables, references, assignments, and nested namespaces. This layer is valuable for linters, refactoring tools, educational software, static analysis, and closure inspection.
A symbol table does not execute the program and cannot resolve every dynamic behavior. Assignments through globals(), setattr(), dynamic imports, decorators, metaclasses, and monkey patching remain beyond complete static understanding. Even so, the table reflects the compiler’s actual lexical-scope decisions and is more reliable than guessing bindings from source text alone.
Create a symbol table
symtable.symtable() accepts source code, a filename, and a compilation mode.
import symtable
source = """
x = 10
def add(y):
z = x + y
return z
"""
table = symtable.symtable(source, "example.py", "exec")
print(table.get_type())
The mode can be exec, eval, or single, matching the modes accepted by compile().
Handle syntax errors
Symbol-table construction uses the compiler and can raise SyntaxError.
try:
table = symtable.symtable(source, path, "exec")
except SyntaxError as error:
report(path, error.lineno, error.offset, error.msg)
When analyzing a project, record the error for one file and continue with the rest rather than discarding the whole batch.
The top-level SymbolTable
The root table represents the module, expression, or interactive input.
print(table.get_name())
print(table.get_type())
print(table.get_lineno())
The name and source line are useful for diagnostics, although their exact meaning depends on the table type and Python version.
List identifiers
get_identifiers() returns the names known to a scope.
for name in sorted(table.get_identifiers()):
print(name)
The result includes bindings and references that matter to the compiler, not merely names that appear on the left side of an assignment.
Inspect a name with lookup
lookup(name) returns a Symbol object containing classification information.
symbol = table.lookup("x")
print(symbol.is_global())
print(symbol.is_assigned())
print(symbol.is_referenced())
Looking up an unknown name can raise an error, so check identifiers first or handle the exception.
Local variables
is_local() reports that a name belongs to the current local scope.
function_table = table.lookup("add").get_namespace()
for name in function_table.get_identifiers():
symbol = function_table.lookup(name)
if symbol.is_local():
print("local", name)
Parameters are also local names, but they have the more specific is_parameter() classification.
Parameters
Function tables expose parameter names directly.
for name in function_table.get_parameters():
print(name)
This is useful for detecting unused parameters, shadowing, naming-policy violations, and arguments required only by an interface.
Implicit and declared globals
A name can be global because no local binding exists or because the function contains an explicit global statement.
counter = 0
def increment():
global counter
counter += 1
Use is_global() and, where available, is_declared_global() to distinguish cases when the difference matters to a diagnostic.
Nonlocal names
nonlocal binds a name to an enclosing function scope rather than the module.
def outer():
total = 0
def inner():
nonlocal total
total += 1
return total
return inner
The inner symbol can be recognized with is_nonlocal().
Free variables
A free variable is read by a nested function but supplied by an enclosing scope.
inner_table = outer_table.get_children()[0]
print(inner_table.get_frees())
These names participate in closure construction and eventually appear in code-object metadata.
Cell variables
When a local variable is captured by a nested function, the compiler stores it in a cell. The symbol-table API can reveal the relationship by comparing locals in the outer table with free variables in children.
After compilation, code objects expose related details through co_cellvars and co_freevars. Use symbol tables for source-level scope analysis and code objects for post-compilation inspection.
Imported names
is_imported() reports names introduced by import statements.
import json as serializer
from pathlib import Path
The local alias, not necessarily the original module name, becomes the binding that appears in the table.
Assignments
is_assigned() identifies names that receive a binding in the current scope.
Assignments include more than the = operator: loop targets, imports, definitions, exception targets, comprehensions, and pattern-matching captures can all create bindings. Combine the table with AST nodes when you need to classify the source of the assignment.
References
is_referenced() indicates that the name is used in an expression or another relevant operation.
A value that is assigned but never referenced may deserve a warning, but tools should account for public APIs, required callback signatures, decorators, side effects, and naming conventions such as a leading underscore.
Nested namespaces
A symbol representing a function or class may own one or more child symbol tables.
symbol = table.lookup("add")
if symbol.is_namespace():
namespace = symbol.get_namespace()
get_namespaces() is useful when one source name can correspond to multiple nested namespaces in supported language constructs.
Traverse child tables
get_children() returns nested tables.
def display(table, level=0):
print(" " * level, table.get_type(), table.get_name())
for child in table.get_children():
display(child, level + 1)
This produces a tree of module, function, class, lambda, comprehension, and other compiler-created scopes.
Function-table helpers
Function symbol tables provide helpers such as get_parameters(), get_locals(), get_globals(), get_nonlocals(), and get_frees(), depending on the Python release.
Use feature detection when supporting multiple versions rather than assuming every helper exists everywhere.
Class scopes differ from function scopes
A class body executes in its own namespace, but methods do not automatically capture class attributes as lexical variables.
class Example:
value = 10
def method(self):
return value
Inside the method, value does not automatically mean Example.value. Attribute access must normally be explicit.
The implicit __class__ cell
The compiler can create a special __class__ reference for features such as zero-argument super().
Analysis tools must be prepared for compiler-generated or implicit symbols that do not look like ordinary user assignments.
Lambda scopes
A lambda creates a function scope and appears as a child table.
double = lambda x: x * 2
The table name can reflect an internal compiler label rather than a user-chosen identifier.
Comprehension scopes
Modern Python comprehensions have their own scope, so the iteration variable does not leak into the enclosing block.
squares = [x * x for x in values]
The symbol table can expose a child namespace associated with the comprehension.
Generator expressions
Generator expressions also create internal scopes and can capture names from their surroundings.
Inspect child tables to find free variables and implicit parameters used by the generated code.
Async functions
Asynchronous functions follow ordinary lexical-scope rules even though their execution and suspension behavior differ.
A symbol table cannot determine whether a coroutine is awaited correctly; that requires AST and control-flow analysis.
Annotations
Type annotations can introduce references and bindings according to syntax and Python version. Deferred annotation behavior and newer annotation mechanisms affect when expressions are evaluated.
Do not infer complete runtime behavior from the presence of a symbol in an annotation-related scope.
Type aliases and newer table types
Recent language releases introduce constructs that can create additional symbol-table types, including scopes related to type parameters and aliases.
Use the enums and APIs of the running Python rather than comparing one hard-coded set of strings. Add version-specific tests for new syntax.
Pattern matching bindings
Structural pattern matching can bind local names.
match value:
case {"id": identifier}:
use(identifier)
The symbol table records the binding, while the AST explains that it originated from a pattern.
Exception targets
The name in except Exception as error is a local binding with special cleanup rules after the handler.
A symbol table does not simulate time, so checking whether a name is available after a particular statement requires control-flow analysis.
Deletion
del name affects a binding, but the table is not a timeline of values.
Detecting use before definition or use after deletion requires a control-flow graph and data-flow propagation.
Understanding UnboundLocalError
An assignment anywhere in a function can cause the compiler to classify a name as local, even when a read appears first.
x = 10
def example():
print(x)
x = 20
The symbol table explains why x is local and why execution raises UnboundLocalError.
Shadowing
A local variable can shadow an import, built-in, parameter, or enclosing name.
Not all shadowing is wrong. A linter should consider scope length, public conventions, readability, and whether the original symbol remains needed.
Built-in names
A name that is neither local nor explicitly global may be resolved from built-ins at runtime.
The symbol table cannot guarantee the original built-in will be used because globals and __builtins__ can be altered dynamically.
Safer renaming
For a reliable variable rename, combine tokens for precise source spans, AST for syntactic context, and symbol tables for binding classification.
Do not replace every textual occurrence. Attributes, dictionary keys, strings, comments, and names from other scopes are different entities.
Find unused parameters
Compare function parameters with referenced symbols in the same namespace.
Ignore conventional placeholders such as _ and parameters required by protocols, callbacks, decorators, framework hooks, or overridden methods.
Find unused imports
An imported symbol that is never referenced can be a cleanup opportunity, but imports may intentionally register plugins or trigger side effects.
Support exceptions, re-exports, and __all__ before applying automatic deletion.
Global mutable state
The table can identify global access, but it does not determine mutability, thread safety, or whether an operation modifies a referenced object.
Use AST and data-flow analysis to distinguish reading a global object from mutating shared state.
Combine with tokenize
tokenize provides comments, exact spelling, and locations. Symbol tables provide binding and scope.
See Python tokenize for the lexical layer.
Combine with AST
The AST shows where a name appears and what construct contains it. The symbol table shows how the compiler classifies the name.
See Python ast for structural analysis.
Combine with dis
After symbol analysis, the compiler selects appropriate load and store instructions. Comparing the table with bytecode reveals the result of those decisions.
See Python dis.
Project-wide analysis
Each file has its own module table. Resolving imports, re-exports, package aliases, and public APIs requires a project index.
Do not execute imports during static analysis because imported modules may perform arbitrary side effects.
Read source with the right encoding
Use tokenize.open() to read Python source before passing text to symtable().
Preserve the real filename so syntax errors and diagnostics point to the correct location.
Limit large inputs
When analyzing uploads or untrusted repositories, limit bytes, lines, nesting, files, and total processing time.
Public services should run compiler-based analysis in an isolated worker process with CPU and memory boundaries.
Python-version differences
Table types, helper methods, and classifications evolve as new syntax is added.
Test every supported Python version and use capability checks instead of assuming one runtime’s behavior.
symtable is not a type checker
The module does not resolve types, overloads, protocols, generics, or inference. It describes lexical name binding.
Integrate a dedicated type checker when type semantics matter.
symtable is not control-flow analysis
The table does not report execution order, reachability, or whether a variable is defined on every path.
Build a control-flow graph or use an existing analysis framework for temporal rules.
Security boundaries
A clean symbol table does not make code safe to execute. Imports, decorators, descriptors, and runtime calls can perform arbitrary actions.
Never use a symbol-name allowlist as a sandbox.
Testing
Cover modules, functions, nested functions, closures, classes, lambdas, comprehensions, generators, async functions, global, nonlocal, annotations, pattern matching, and syntax errors.
Only compare static results with execution for trusted fixtures.
Common mistakes
Common failures include inferring bindings from text alone, confusing implicit and declared globals, ignoring comprehension scopes, treating class bodies like functions, renaming without namespace awareness, using symtable as a type checker, executing imports to resolve names, and depending on APIs from one Python version only.
Conclusion
symtable reveals how Python’s compiler organizes names and scopes before bytecode generation. Use lookup() and Symbol properties to identify locals, globals, imports, parameters, nonlocals, and free variables, and traverse get_children() to build the namespace tree.
Combine symbol tables with tokens, AST, and control-flow analysis for precise linting and refactoring. Consult the official symtable documentation.







