Python symtable: Scopes and Symbols

Published on: August 3, 2026
Reading time: 6 minutes
Developer analyzing code structure and symbol tables with Python symtable

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(), and is_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.py

Without 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.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Monitor with binary code representing bytecode analysis with Python dis
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python dis: Understand Bytecode

    Learn Python dis to inspect bytecode instructions, adaptive caches, source positions, tracebacks, and CPython implementation details.

    Ler mais

    Tempo de leitura: 6 minutos
    03/08/2026
    Error screen representing crash and deadlock diagnosis with Python faulthandler
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python faulthandler: Diagnose Crashes

    Learn Python faulthandler to diagnose crashes, deadlocks, and timeouts using thread dumps and native C stack information.

    Ler mais

    Tempo de leitura: 6 minutos
    03/08/2026
    Laptop with code representing Python traceback analysis and debugging
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python traceback: Errors and Call Stacks

    Learn Python traceback to capture, format, and log error call stacks safely without leaking sensitive data or retaining memory.

    Ler mais

    Tempo de leitura: 6 minutos
    03/08/2026
    Software analysis representing object introspection with Python inspect
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python inspect: Object Introspection

    Learn Python inspect to analyze functions, classes, signatures, source code, decorators, generators, coroutines, and frames safely.

    Ler mais

    Tempo de leitura: 6 minutos
    02/08/2026
    Memory module representing weak references and caches in Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python weakref: Weak References

    Learn Python weakref to build weak references, automatic caches, observer registries, and finalizers without retaining objects in memory.

    Ler mais

    Tempo de leitura: 8 minutos
    28/07/2026
    ZIP archive icon for a Python zipfile article
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python zipfile: Safe ZIP Archives

    Learn how to create, read, validate, and extract ZIP archives with Python zipfile in a predictable and secure workflow.

    Ler mais

    Tempo de leitura: 4 minutos
    27/07/2026