做 Python 开发的人几乎没人能绕开这行报错UnboundLocalError: local variable xxx referenced before assignment我第一次遇到它是在写一个计数统计脚本的时候函数里明明在文件顶部定义了变量结果一运行就给我甩这个脸子。当时第一反应是“Python 是不是抽风了”后来才明白这不光是语法层面的小毛病背后藏的是 Python 作用域机制的一个经典规则。这篇文章就专门把这个报错掰开揉碎讲清楚它到底在说什么、为什么会出现、怎么才能不踩坑。不管你是刚入门 Python 的新手还是写了好几年代码但没系统梳理过作用域规则的老手这篇文章都能帮你彻底搞定它。读完你能直接定位到自己代码里的问题并且知道至少三种不别扭的改法。1. 这个报错到底在说什么1.1 错误信息的完整解读先看这条报错本身的组成。UnboundLocalError是NameError的子类意思是“变量没有被绑定就被拿来用了”。后半句local variable xxx referenced before assignment翻译过来是局部变量xxx在赋值之前就被引用了。注意关键词local variable局部变量。报错不是在说“你没定义这个变量”而是在说“解释器已经认定xxx是一个局部变量但在这个局部作用域里你还没来得及给它赋值就先用它了”。这跟NameError: name xxx is not defined不一样。后者是解释器完全找不到这个名字前者是解释器找得到名字但它认为这个变量已经被你“声明”成了函数内部的局部变量可是执行到使用它的地方时这个局部变量还没有值。我见过很多人在网上提问时贴的报错截图里混着这两种错误排查方向完全不同。NameError一般是拼写错误或者真没定义UnboundLocalError百分之九十九是作用域误判问题。用一个生活化的类比来理解就好比你在一家公司里总公司的账本上明明有一笔“备用金”但分公司经理在自己的账本上写了一个同名条目“备用金”还没填金额就直接拿去买东西了。收银员一看你名下这笔钱还没入账没法付款。Python 的规则是只要你在函数内部给某个名字赋过值这个名字在函数内从头到尾都被当作“分公司的条目”来处理哪怕赋值语句在函数末尾才出现。1.2 为什么是“先引用后赋值”关键点在于 Python 对变量作用域的判定方式。Python 不像 C 或 Java 那样需要显式声明变量它靠的是“赋值即定义”的规则在函数体内任何地方出现对某个名字的赋值操作都会让 Python 把整个函数内对这个名字的引用都当成局部变量处理。这里有一个特别容易让人产生误解的点判定作用域的时刻是“编译期”不是“运行期”。Python 在解析函数代码的时候会先扫描整个函数体找出所有被赋值的名字把它们提前标记为局部变量。等真正执行到赋值语句之前的代码如果提前引用了这个名字解释器只会去局部作用域里找自然找不到于是抛出UnboundLocalError。为了验证这一点你可以在本地跑一段这样的代码def demo(): print(x) x 42 demo()运行结果是UnboundLocalError: local variable x referenced before assignment这种写法在别的语言里可能会先打印出外层作用域的x值但 Python 不会。因为在demo()函数体内第二行x 42已经把x标记为局部变量print(x)里的x变成“一个还没有值的局部变量”。这个机制是理解本题的根基后面所有解法都是围绕它展开的。2. 最常见的触发场景2.1 函数内使用全局变量但不小心重名这是最典型的场景。你在一开始写了一行全局变量counter 0 def increment(): counter counter 1 return counter运行increment()时立刻报错。原因很清楚函数内counter counter 1这个赋值操作让 Python 判定counter是局部变量那么等号右边引用counter时它去找局部变量counter可这时候局部counter还没初始化于是报错。很多小白会困惑“我明明在文件顶部定义了counter为什么函数里不认”因为 Python 的默认规则就是在函数里能读全局变量但要想修改全局变量必须显式声明。你那个counter counter 1不叫“修改全局变量”在 Python 眼里这叫“创建了一个新的局部变量然后试图用它自己的旧值来计算新值”。2.2 列表或字典的“原地操作”反而是安全的有些同学可能听说过“列表和字典在函数里可以直接修改”这句话然后就会很困惑我直接往全局列表append一下怎么不报错items [] def add_item(item): items.append(item) return items add_item(apple)这段代码不会报错因为items.append(item)并没有对items这个变量名本身赋值只是调用了它指向对象的append方法。Python 的局部变量判定只看“有没有对名字赋值”不看“有没有对名字指向的对象做修改”。所以读全局列表的值、调用它的方法都是允许的但如果你在函数里写了items items [item]立刻就会触发UnboundLocalError因为出现了对名字的赋值。这里藏着很多隐藏 bug你在函数里调用一个全局列表的extend、append、sort都没问题但一旦你想用items [item]或者items items [item]前者在部分场景下会报错后者必定报错。原因就是在 Python 里会对变量进行重新绑定它也算赋值操作。2.3 条件分支里才赋值的变量还有一种情况是变量在条件分支中赋值但不是所有分支都覆盖到了。def process(flag): if flag: result 100 print(result)调用process(True)没问题调用process(False)就会报UnboundLocalError。因为result只在if flag为真时被赋值为假时整个函数里result从未被绑定而print(result)却试图读取它。这个场景的本质不是作用域的锅而是“运行期控制流”与“编译期作用域判定”叠加的效果。Python 提早把result判定为局部变量没问题要命的是局部变量在某些执行路径上没有被赋值。这种 bug 比全局变量问题更隐蔽因为它在特定输入下才会出现。你测试时传了True一切正常生产环境某个用户传了False线上立刻崩。2.4 循环和异常处理里的特殊例子循环本身不会创建新的作用域这一点 Python 跟很多语言不一样。但如果你在循环体里给变量赋值又在循环外使用它可能在循环一次都没执行的情况下报错。def find_first_even(numbers): for n in numbers: if n % 2 0: found n break return found如果传入的numbers是空列表或者列表里没有任何偶数found就从未被赋值return found直接报UnboundLocalError。异常处理同样如此def safe_divide(a, b): try: result a / b except ZeroDivisionError: print(除零错误) return result传入b0时异常分支只打印不赋值最后的return result就会报错。解决办法很简单在 try 之前先把result初始化成一个默认值比如result None。3. 解决方案与最佳实践3.1 方案一用global关键字声明全局变量如果函数真的要修改一个全局变量最直接的方式就是用global声明counter 0 def increment(): global counter counter counter 1 return counter加上global counter之后解释器会把counter当作全局作用域的变量来看待函数内所有对counter的读写都直接操作全局变量。运行三次increment()全局counter会依次变成1、2、3。这个方案的优点是简单直观特别适合小型脚本。缺点也很明显滥用global会让函数产生副作用函数不再纯粹依赖输入输出代码调试和测试的难度都会增加。到了后期代码量大起来全局变量遍地走改一个值可能牵动一片逻辑追 bug 相当痛苦。我的建议是脚本好写好用项目里慎用。如果是几十行的小工具脚本全局变量无伤大雅如果是组织良好的项目优先考虑后面几种方案。3.2 方案二用return把新值传出来这是我认为最符合 Python 风格的做法不让函数去修改全局变量而是把计算结果通过返回值传递出来由调用方的赋值语句来更新变量。counter 0 def increment(value): return value 1 counter increment(counter)这样写函数本身没有任何全局依赖它接收一个值返回一个值干净利落。调用方明确知道自己是在更新counter变量。测试的时候直接increment(5)就能断言返回6不用关心任何全局状态。这个方案背后是一种很重要的思维转变与其在函数内部想办法绕开作用域限制不如重新设计函数边界让数据通过参数进入、通过返回值出来。这符合函数式编程的思路也让代码更容易被阅读和测试。3.3 方案三把变量放到不可变对象的容器里如果你非要在函数里修改一个“外部状态”还有一个技巧用一个可变对象装这个状态比如列表或字典。state {counter: 0} def increment(): state[counter] 1 return state[counter]刚才讲过state[counter] 1虽然看起来是赋值但它跟state[counter] state[counter] 1一样并没有对变量名state本身赋值只是通过引用修改了字典里的键值。Python 因此不会把它判定为局部变量自然也不会报错。这个方案常用于需要维护状态的场景比global稍微优雅一点代码中能看出状态汇聚在一个容器里。缺点是不够直白第一次看这段代码的人可能会想“为什么不直接用一个全局 int 变量”需要配合注释或者命名来提升可读性。3.4 方案四提前初始化局部变量对于条件分支里才赋值的情况最好的解决方案是在函数体一开始就给变量一个默认值。def process(flag): result None if flag: result 100 print(result) return result这样写无论flag是什么值result都已经绑定了一个默认值永远不会出现未绑定的情况。process(False)会打印并返回None调用方可以通过判断result is None来感知“这个分支没被触发”。对于循环和异常处理场景也是一样的思路def find_first_even(numbers): found None for n in numbers: if n % 2 0: found n break return found注意这里我加入了一个常用的保护在初始化变量后“连续赋值”的写法时尽量用语义明确的默认值比如None而不是随便给一个0或。这样调用方可以区分“没找到”和“找到的值是 0”这两种情况。3.5 方案五闭包场景用nonlocal如果你在嵌套函数中遇到了这个报错比如写一个带内部函数的计数器时global都不好使需要用nonlocal。def make_counter(): count 0 def increment(): nonlocal count count 1 return count return increment counter make_counter() print(counter()) # 输出 1 print(counter()) # 输出 2这里increment函数内部给count赋值Python 默认把它当作increment的局部变量但count其实定义在外层函数make_counter的作用域里。加上nonlocal count之后解释器会沿作用域链向上查找count并直接修改外层函数的局部变量。这个方案在装饰器、状态保持类函数等场景特别常用。nonlocal从 Python 3 开始支持如果你还在维护 Python 2 的老项目那基本只能借助可变容器或者类来实现类似效果了。4. 从作用域规则理解整个问题的本质4.1 LEGB 规则通俗版要彻底理解这道题绕不开 Python 的 LEGB 作用域规则。这四个字母分别代表LLocal当前函数内部的局部作用域EEnclosing外层嵌套函数的作用域GGlobal当前模块的全局作用域BBuilt-inPython 内置名字的作用域Python 在解析一个名字的引用时会按照 L → E → G → B 的顺序一层一层往上找找到第一个匹配的就停下来。这个规则绝大多数程序员都知道但很多人忽略的是这个查找顺序只适用于“读”操作不适用于“写”操作。对变量名进行赋值时Python 默认把它放在当前作用域而不是去外层作用域找现成的变量。这就能解释为什么函数内读一个全局变量不报错但一旦给同名变量赋值就报错。读操作按 LEGB 向上查找找到了全局变量所以没问题写操作直接在当前局部作用域创建新变量后续对该名字的所有引用都锁定了这个局部变量前面还没赋值时去读自然触发UnboundLocalError。4.2 Python 为什么设计得这么“另类”很多人从 C、Java 转过来对这套规则很不适应。C 语言里可以通过块级作用域控制变量生命周期Java 里类字段和局部变量界限分明为什么 Python 非得搞“函数内赋值即局部”的规则深层原因是 Python 的动态特性和简洁哲学。Python 不需要var、let这类声明关键字变量在首次赋值时自动诞生这让代码写起来极其灵活。但这种设计必须有一套清晰可预期的判定规则如果解释器在运行到赋值语句之前先看到引用它没法预测这个变量是全局的还是局部的与其四处猜测不如在编译期统一扫描所有赋值语句把所有赋过值的名字都默认为局部变量。这套设计在绝大多数时候是合理的它保证了函数内部代码的“隔离性”——你在函数里写的变量不会不小心污染全局命名空间这也是 Python 默认推荐的做法。真正出问题的场景都是当你想跨作用域修改变量时而 Python 提供global和nonlocal两个关键字来显式打破默认规则相当于告诉解释器“这个变量虽然是赋值但我说的不是本作用域的变量”。理解了这个设计哲学你就不会再骂 Python 反直觉了反而能明白这种“赋值即局部”默认规则带来的确定性远大于偶尔需要显式声明带来的麻烦。4.3 补充条件判断里的“最隐晦”类错误有一种场景特别容易迷惑人。看下面这段代码def check(): if x not in locals(): x 1 print(x)理论上locals()返回当前局部命名空间的字典if x not in locals()为真时执行赋值看起来不会报错。但实际上 Python 编译函数时已经把x标记为局部变量locals()里没有x这个键条件为真于是执行x 1一切看似正常。如果改成下面这种写法就有意思了def check(): if x in locals(): print(exists) x 1你可能会想分支里读取locals()不会出问题这个函数编译时虽然把x标记为局部变量但读取locals()本身又没涉及对x的引用……是的这个函数确实不会报错。这说明locals()是一种特殊的“查看正在整理的抽屉”手段不是你要找的变量。这类代码不建议在实际项目中写太绕了。真正安全的做法永远是“先赋值后使用”或者把默认值初始化放在函数最前面让变量的生命周期从一开始就清晰可查。5. 实际排查与避坑技巧5.1 常见问题速查表我把这个报错的常见触发点和对应解决策略整理成一个表以后遇到直接对照排查场景描述典型代码特征推荐解决方案函数内想改全局变量函数体内出现counter counter 1加global声明或改为 return 新值条件分支未覆盖赋值路径只在if里给变量赋值函数后续直接使用在函数开头初始化默认值如None循环可能一次都不执行循环体内赋值循环外使用循环前预初始化变量异常分支没有赋值try里赋值except里只打印后续还要用在 try 前给变量初始化默认值列表/字典直接整体赋值items items [item]改用items.append(item)或items.extend(...)嵌套函数修改外层变量内层函数里count 1count 定义在外层函数加nonlocal count声明这张表覆盖了绝大多数UnboundLocalError的触发场景。排查时先判断你的代码属于哪一类再选对应的解决方案基本不会跑偏。5.2 我的实际排查与定位经验如果你手头有一段比较长的函数报了这个错我建议不要盲改先按下面的步骤走第一步看报错堆栈。Python 会把报错定位到具体行号先定位到“引用变量”的那一行确认是哪个变量出问题。第二步在当前函数内搜索这个变量的所有出现位置重点看有没有赋值语句。如果只有引用没有赋值那问题多半是变量根本不属于这个作用域考虑global或nonlocal如果有赋值但赋值在引用之后那问题就是“代码路径顺序”问题考虑提前初始化。第三步检查赋值语句是否在所有可能执行的代码路径上都覆盖到了。用if/else结构时两个分支都要给变量赋值或者先初始化。第四步如果你用了、-这类增强赋值运算符心里必须清楚它也算赋值操作同样会触发局部变量判定。第五步测试时优先考虑边界输入。空列表、空字典、除零、None 值等边界情况最容易暴露这类问题把这些情况跑一遍就能大概率复现线上 bug。我当年踩过最深的一个坑是在类方法里写计数逻辑本来想修改self.counter结果写成了counter 1忘了加self前缀于是 Python 把counter当成方法内的局部变量报错。这类前缀遗漏的问题在类里特别常见排查时要注意区分self.xxx和裸变量名。还有一个容易被忽略的场景是列表推导式和生成器表达式的内部变量。Python 3 中列表推导式有自己的作用域但推导式内给变量赋值的逻辑跟普通函数不太一样一般不触发UnboundLocalError。真正会触发的是生成器函数里的yield和赋值混用场景def gen(): print(val) val 10 yield val生成器代码中同样遵循“赋值即局部”的规则print(val)在val 10之前执行会直接报错。5.3 关于“修复后仍然报错”的处理有一种情况是你确定已经加了global但函数仍然报同样的错。常见原因是代码里有两处同名变量你在函数开头写了一个global counter但后面某一行不小心写了counter的别名比如count或者函数里还存在另一个局部变量遮蔽了它。另一个常见原因是在类的方法里你写的是global config但配置变量实际存在模块的另一个命名空间里比如你用了包结构config定义在config.py文件里而你当前模块只是from config import something这种情况下global只能引用当前模块的全局命名空间。多文件项目里的“全局”要特别注意。global的“全局”指的是当前模块顶层的名字而不是项目所有文件共享的全局。如果你真想跨模块共享状态应该用from module import config的方式导入然后通过config.xxx ...来操作对象属性或者设计一个全局配置类。还有一个小细节global声明必须放在函数内对变量进行任何引用或赋值之前不能放在赋值语句之后。虽然 Python 的语法允许你把global放在函数中间但可读性和可维护性都很差标准写法永远是函数体最前面集中声明。5.4 性能层面的额外提醒如果你很在意性能我建议尽量避免在热点函数里频繁使用global或nonlocal。每一次通过global修改变量解释器都需要跳到模块级命名空间做查找和更新而局部变量走的是快速通道。在极端情况下高频调用的函数中global查询开销会肉眼可见地拖慢速度。我不是说完全不能用global而是要在高性能循环或频繁调用的小函数里避开它。追求高吞吐的场景优先用参数传递和返回值或者把共享状态封装成可变对象传入。我实测过一个小测试一个函数内部循环一千万次更新局部变量对比每次通过global更新耗时差距大概在 15% 到 25% 之间。日常业务代码不需要计较这点差异但在数据处理、数值计算这类热循环里这是个不该忽略的优化点。6. 送给不同阶段开发者的几条实用建议如果你刚学 Python 没多久建议你花十分钟把 LEGB 规则在笔头上默写一遍再去手动模拟几个小函数的执行流程比如写一个不执行global但读取全局列表的代码、一个不带global但修改全局列表的代码、一个带global修改全局整数的代码把三个例子的执行结果都自己想一遍再验证。这样做一次之后我对作用域这件事算是彻底开窍了。如果你已经在写项目了建议把“不要依赖全局变量”当成默认准则。函数尽量做到“输入参数 返回值”的纯净模式状态管理交给类的属性或专门的状态容器。这样不只避开UnboundLocalError还会让整体代码结构更容易测试和演进。如果你正在排查一个诡异的老项目贴了满屏global和全局列表千万记得先画清楚数据流向再动手。把这个报错解决只是第一步把状态依赖理清楚才是根治。对我来说这类报错早就不算“bug”了更像是 Python 在编译期主动提醒我你的数据流设计可能有问题。顺着它去检查作用域和赋值路径往往能顺带清理出不少潜在隐患。希望这篇文章能帮你把这个报错从“看着眼晕”变成“一眼看穿”。