Program-Aided Language Models (PAL)
What Is PAL? Program-Aided Language Models (PAL) is a technique in which the language model does not compute the final answer itself. Instead, it writes a program that computes the answer, and a real interpreter, such as Python, runs the program to produce the result.
Chain-of-thought: problem → model reasons in text and calculates → answer PAL: problem → model writes code → Python runs the code → answer In simple terms: the model does the understanding and planning, and the computer does the calculating.
PAL was introduced in the 2022 paper "PAL: Program-aided Language Models" by Gao et al., which reported better accuracy than chain-of-thought prompting on several mathematical and symbolic reasoning benchmarks. A closely related idea, Program of Thoughts (Chen et al., 2022), was published at about the same time.
Example Problem:
A course costs ₹6,000. Early registration gives 15% off. GST of 18% is added after the discount, and then a ₹200 coupon is applied. What is the final price? Chain-of-thought asks the model to calculate each step in text, where an arithmetic slip is possible (Lesson 44).
PAL asks the model to write code:
price = 6000 discounted = price * (1 - 0.15) with_gst = discounted * 1.18 final_price = with_gst - 200 print(round(final_price, 2)) Python runs it and prints 5818.0. The model never performs a multiplication itself.
Why PAL Works Models Are Good at Translating, Weak at Exact Calculation Language models are skilled at understanding problem descriptions and translating them into structured steps. They are less reliable at exact arithmetic, especially with long numbers, many steps, or decimals, because they generate digits as tokens rather than computing them (Lesson 2).
PAL assigns each job to the component that does it best:
Job Done By Why Understanding the problem Model Language understanding Choosing the steps and their order Model Planning Writing the steps as code Model Code is a precise, unambiguous language Performing calculations Interpreter Exact and deterministic Code Removes Ambiguity Text reasoning such as "subtract the discount and add tax" can be interpreted in several ways. Code must state exactly which value is multiplied by what, in which order. Writing the program forces the model to be precise.
Results Are Verifiable A program can be read, tested, and re-run. If the result is wrong, you can see whether the logic (the program) or the interpretation of the problem was wrong.
Two Ways to Use PAL Safely Running code produced by a model raises an important question: what if the code is harmful? Generated code could, by mistake or because of manipulated input, delete files, read secrets, or make network requests. This lesson shows two approaches with different levels of risk.
Approach What the Model Produces How It Is Run Risk
- Expression-only PAL A single arithmetic expression A safe evaluator that only allows numbers and operators Very low
- Program PAL A full Python program An isolated process with a time limit, and in production, a real sandbox Requires proper isolation Approach 1: Expression-Only PAL For pure arithmetic, the model only needs to produce an expression, such as (6000 - 6000 0.15) 1.18 - 200. The expression can be evaluated with a small evaluator that refuses anything except numbers, arithmetic operators, and parentheses.
A Safe Arithmetic Evaluator import ast import operator from fractions import Fraction
OPERATORS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.USub: operator.neg, ast.UAdd: operator.pos, }
def safe_eval(expression: str) -> Fraction: """Evaluate an expression with only numbers, + - * / and parentheses."""
def evaluate(node): if isinstance(node, ast.Expression): return evaluate(node.body) if ( isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool) ): return Fraction(str(node.value)) if isinstance(node, ast.BinOp) and type(node.op) in OPERATORS: return OPERATORStype(node.op), evaluate(node.right)) if isinstance(node, ast.UnaryOp) and type(node.op) in OPERATORS: return OPERATORStype(node.op)) raise ValueError("Unsupported element in expression")
return evaluate(ast.parse(expression, mode="eval"))
tests = [ "(6000 - 6000 0.15) 1.18 - 200", "60 - 60*15/100 - (60 - 60*15/100)/3", "2**100", "import('os').system('ls')", ] for expression in tests: try: print(expression, "→", safe_eval(expression)) except (ValueError, ZeroDivisionError) as error: print(expression, "→ rejected:", error) Output:
(6000 - 6000 0.15) 1.18 - 200 → 5818 60 - 60*15/100 - (60 - 60*15/100)/3 → 34 2**100 → rejected: Unsupported element in expression import('os').system('ls') → rejected: Unsupported element in expression Code Explanation ast.parse(expression, mode="eval") Python's ast module converts the expression into a syntax tree without running it. Each part of the expression becomes a node, such as a number, an operation, or a function call.
evaluate() Walks the tree and allows only four kinds of nodes:
The top-level expression. Numeric constants, converted to exact Fraction values. bool is excluded explicitly, because in Python True counts as an integer. Binary operations (+ - * /) from the OPERATORS table. Unary plus and minus, for numbers such as -5. Anything else, such as function calls, attribute access, names, or the power operator, raises an error. The attempt to call import('os') is rejected before anything runs.
Why Fraction? Converting 0.15 to Fraction("0.15") keeps the value exact, so the result is exactly 5818, not 5817.999999999999.
Why reject ? Exponentiation can create enormous numbers, such as 99**9, which can freeze the program. Excluding it keeps the evaluator safe; add it back only with limits if your tasks need it.
Warning: Never use Python's built-in eval() on model output. eval() executes any Python code, including code that deletes files or reads secrets.
Asking the Model for an Expression Requirements: pip install openai and an OPENAI_API_KEY environment variable.
import re
from openai import OpenAI
client = OpenAI() MODEL = "gpt-5.4" # Replace with a model available in your account
EXPRESSION_INSTRUCTIONS = """Translate the word problem in into a single arithmetic expression that computes the answer. Use only numbers, + - * / and parentheses. Do not calculate the result yourself. Return only the expression inside tags."""
def solve_with_expression(problem: str) -> tuple[str, Fraction]: response = client.responses.create( model=MODEL, instructions=EXPRESSION_INSTRUCTIONS, input=f"\n{problem}\n", ) match = re.search(r"\s*(.?)\s", response.output_text, re.DOTALL) if not match: raise ValueError("No expression found in the response") expression = match.group(1) return expression, safe_eval(expression)
expression, value = solve_with_expression( "A batch starts with 60 students. 15% leave in the first month. Of the remaining " "students, one-third choose the evening batch. How many are in the day batch?" ) print(expression, "=", value) Code Explanation The instructions ask for an expression, explicitly forbid calculating, and limit the allowed symbols. The expression is extracted from tags (Lesson 37) and passed to safe_eval(). If the model produces anything unsafe or invalid, safe_eval() raises an error instead of running it. Approach 2: Program PAL Some problems need more than an expression: dates, loops, conditions, or lists. For these, the model writes a full program.
Example Problem A student bought a course on 24 October 2026. Refunds are allowed for 7 days, and course access lasts 180 days. On what date does the refund window end, and on what date does access end? Date arithmetic is a classic source of model errors: months have different lengths and years change. Python's datetime module handles this exactly.
Running Generated Programs With Basic Isolation import os import re import subprocess import sys import tempfile
def extract_code(text: str) -> str: """Return the code inside a python block, or the whole text if there is none."""
match = re.search(r"(?:python)?\s*\n(.*?)`", text, re.DOTALL) return match.group(1) if match else text
def run_program(code: str, timeout: int = 5) -> tuple[str | None, str | None]: """Run code in a separate Python process. Return (stdout, error).""" with tempfile.TemporaryDirectory() as workdir: path = os.path.join(workdir, "program.py") with open(path, "w", encoding="utf-8") as file: file.write(code) try: result = subprocess.run( [sys.executable, "-I", path], capture_output=True, text=True, timeout=timeout, cwd=workdir, ) except subprocess.TimeoutExpired: return None, "Timed out" if result.returncode != 0: return None, result.stderr.strip().splitlines()[-1] return result.stdout.strip(), None
model_reply = """`python from datetime import date, timedelta
purchase = date(2026, 10, 24) refund_deadline = purchase + timedelta(days=7) access_end = purchase + timedelta(days=180) print(refund_deadline.isoformat(), access_end.isoformat())
print(run_program(extract_code(model_reply)))
print(run_program("print(1 / 0)"))
Output:
('2026-10-31 2027-04-22', None)
(None, 'ZeroDivisionError: division by zero')
Code Explanation
extract_code() Models often wrap code in a Markdown code block. The regular expression extracts the code between the opening and closing backticks.
run_program()
Writes the code to a file in a temporary directory, which is deleted afterward.
Runs it in a separate process using the same Python interpreter (sys.executable).
-I starts Python in isolated mode, which ignores environment variables like PYTHONPATH and does not add the current directory or user site-packages to the module search path.
timeout=5 stops programs that run too long, such as infinite loops.
cwd=workdir makes the temporary directory the working directory.
Returns either the printed output or the last line of the error, which is usually the most informative.
The result The refund window ends on 31 October 2026, and access ends on 22 April 2027. Counting 180 days across months of different lengths by hand is error-prone; the interpreter does it exactly.
This Is Not a Full Sandbox
Warning: run_program() limits time and isolates some settings, but the program can still read and write files your user account can access, use the network, and consume memory. It is suitable for learning and for trusted, local experiments only.
For production systems, run generated code in a real sandbox: for example, a container or virtual machine with no network access, no access to secrets or your file system, and limits on CPU, memory, and execution time. Managed code-execution services and tools offered by model providers are often a safer choice than building your own.
Generating the Program With a Model
PROGRAM_INSTRUCTIONS = """Write a short Python program that solves the problem in <problem>.
Use only the Python standard library. Store values in clearly named variables.
The program must print only the final answer.
Return only the code in a ```python code block."""
def solve_with_program(problem: str) -> tuple[str, str | None, str | None]:
response = client.responses.create(
model=MODEL,
instructions=PROGRAM_INSTRUCTIONS,
input=f"<problem>\n{problem}\n</problem>",
)
code = extract_code(response.output_text)
output, error = run_program(code)
return code, output, error
Explanation: The instructions require clearly named variables (which makes the program readable as reasoning), the standard library only (no installations needed), and output of only the final answer (which makes the result easy to use). If the program fails, the error message can be sent back to the model to request a corrected program, which is the idea behind Reflexion (Lesson 60).
PAL Compared With Chain-of-Thought
Aspect Chain-of-Thought PAL
Who calculates The model The interpreter
Arithmetic errors Possible Eliminated for the computed steps
Logic errors (wrong formula, wrong order) Possible Still possible: the program can encode the wrong logic
Verifiability Read the reasoning Read, test, and re-run the code
Safety concerns None Generated code must be executed safely
Best for Reasoning in language, judgment Arithmetic, dates, counting, data manipulation
What PAL Does Not Fix
PAL removes calculation errors, not understanding errors. If the model misreads the problem, for example applying one-third to 60 instead of to the remaining students, the program will compute the wrong thing exactly. Combining PAL with self-consistency, by generating several programs and voting on their outputs, reduces this risk (Lesson 53).
When to Use PAL
Good Fit Why
Multi-step arithmetic Exact computation
Date and time calculations Calendars are complex and error-prone
Counting, sorting, and filtering data provided in the prompt Code handles these exactly
Unit conversions and financial calculations Precision matters
Poor Fit Why
Questions requiring language judgment No computation involved
Environments where code cannot be executed safely Risk outweighs benefit
Simple one-step arithmetic An expression or direct answer is enough
Common Mistakes
Mistake 1: Using eval() or exec() Directly
What it is: Running model-generated code in your application's own process.
Why it happens: It is the shortest way to run code.
What it causes: Generated or injected code can access files, secrets, and the network with your application's permissions.
How to fix it: Use a restricted evaluator for expressions, and a real sandbox for programs.
Mistake 2: Assuming the Program's Logic Is Correct
What it is: Trusting the output because it was computed by Python.
Why it happens: Computer results feel authoritative.
What it causes: Exact answers to the wrong calculation.
How to fix it: Review the program logic, test with known cases, and use multiple programs with voting for important results.
Mistake 3: No Time Limit
What it is: Running generated code without a timeout.
Why it happens: Test programs always finish quickly.
What it causes: An infinite loop can hang the application.
How to fix it: Always set a time limit, and limit memory in production.
Mistake 4: Programs That Print Too Much
What it is: Generated code prints explanations or intermediate values along with the answer.
Why it happens: The prompt does not restrict output.
What it causes: The answer is hard to extract.
How to fix it: Instruct the program to print only the final answer, and validate the output format.
Best Practices
Let the model translate and the interpreter calculate. Each does what it is good at.
Prefer expression-only PAL for arithmetic. It is the safest approach.
Never run generated code with eval() or in your main process. Isolate it.
Use a real sandbox in production. No network, no secrets, strict resource limits.
Require readable variable names. The program becomes inspectable reasoning.
Verify logic, not just execution. Test with known answers and consider voting across programs.
Try It Yourself
Exercise 1: Expression PAL vs Chain-of-Thought
Write 10 multi-step fee and discount problems with known answers. Solve each with a chain-of-thought prompt and with solve_with_expression(). Compare accuracy, and for each wrong answer, decide whether it was a calculation error or an understanding error.
Exercise 2: Date Problems
Write five date-based questions, such as "If a 45-day course starts on 20 December 2026, on what date does it end?" Solve them with solve_with_program() and check each result with your own datetime code.
Exercise 3: Extend the Safe Evaluator
Add support for the % (modulo) operator to safe_eval(), and add an optional power operator that rejects exponents larger than 10. Write tests showing that 7 % 3 works, 2 ** 8 works, and 2 ** 1000 is rejected.