ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

python-dotenv 用法详解:环境变量加载、覆盖规则与安全实践

python-dotenv 用法详解:环境变量加载、覆盖规则与安全实践

不知道你有没有遇到过这样的场景:项目里数据库密码、第三方 API 的 Key、对象存储的 AccessKey 直接以字符串形式写死在代码文件里。本地跑没有任何问题,但代码一旦提交到仓库,这些密钥就跟着走了。我见过不止一个团队因为这种习惯,上线后不久就被自动化脚本扫描到硬编码的云服务密钥,测试环境被拖库、资源被刷爆,排查起来无比痛苦。那之后我给自己定了一条规矩:任何 Python 项目启动的第一步,不是写业务逻辑,而是先把环境变量的加载链路搭好。python-dotenv 就是这条链路里最顺手、生态最成熟的一个工具。

这篇文章我想完整聊一聊 python-dotenv 的用法,不只是讲load_dotenv()怎么调用,而是把配置文件的优先级、覆盖规则、多环境管理、安全边界和实际排坑经验都过一遍。无论你是刚接触 Python 的新手,还是在团队项目里被环境变量问题困扰过的开发者,这篇都能给你一份可以直接照着用的操作手册。我踩过的坑不会再让你踩一遍。

1. 为什么每个 Python 项目都需要一个加载环境变量的环节

1.1 配置和代码分离:这个老生常谈的问题到底在谈什么

先想一个问题:同一个应用,开发环境连本地数据库,测试环境连 staging 库,生产环境连线上主库。三套地址、三套账号密码,代码逻辑却完全一样。如果你把数据库连接串直接写在.py文件里,那么换一个环境就要改一次代码,改完还得重新走一遍发布流程,万一哪个环境漏改了,后果可能就是测试环境误连了生产库。

环境变量这种机制就是为了解决这个问题而存在的。它把"配置"从"代码"里抽离出来,程序运行时去读操作系统的环境变量,而不是读自己的源码文件。这样同一套代码打包到任何环境,只要提前在环境里设置好对应的变量,程序就能正常工作。这也是业界常说的"配置与代码分离"的核心思想:代码管逻辑,环境管参数。

但环境变量也有一个实际的痛点:本地开发时你得在终端里手动敲一堆export DATABASE_URL=xxx,每次打开新终端都得重新设置,项目成员多了之后每个人记的变量还不一样,内存里充满了各种版本的配置。这种重复劳动很快就会让人崩溃,于是就有了把环境变量写进文件、由程序自动加载的需求。

1.2 dotenv 到底帮你做了什么

python-dotenv 做的事情可以用一句话概括:它把一个名为.env的文件逐行解析出来,然后把里面的键值对写进os.environ这个字典里。之后你的代码就可以用os.getenv()去读取这些配置了。

用最直白的话说,它就是把"手动敲 export 命令"这件事自动化了。你只需要维护一个.env文件,里面写清楚每个变量的值,启动程序时调用一次load_dotenv(),所有配置就都躺在os.environ里等着被读取了。

这里有个容易忽略的原理:os.environ本质上就是一个字典,里面保存着当前进程的全部环境变量。子进程会继承父进程的环境变量,但你用os.environ['KEY'] = 'value'设置的值,只对当前进程及其之后启动的子进程生效,不会修改你操作系统里真正持久化的环境变量配置。dotenv 做的就是这个"内存级"的设置,程序退出后一切恢复原样,这恰恰是它适合本地开发的原因。

2. 五分钟接入:安装、导入与第一次读取

2.1 安装与最小可用示例

安装很简单,一个命令的事:

pip install python-dotenv

如果你用的是 Poetry 或者 uv 这类依赖管理工具,直接把它加进项目依赖即可。装好之后,在项目根目录创建一个.env文件,内容就是普通的键值对格式:

# .env DATABASE_URL=postgresql://app_user:app_pass@127.0.0.1:5432/app_db API_KEY=sk-1234abcd DEBUG=true

然后在你的入口文件里加载它:

# main.py import os from dotenv import load_dotenv load_dotenv() print(os.getenv("DATABASE_URL")) print(os.getenv("API_KEY"))

运行python main.py,你会看到终端里打印出.env文件里对应的值。整个接入过程不超过两分钟,这就是 dotenv 在 Python 生态里如此流行的原因:心智负担极低。

2.2 load_dotenv 和 os.getenv 的关系

很多初学者会困惑:load_dotenv()和os.getenv()到底是啥关系?其实它们是两件事。load_dotenv()负责把.env文件里的内容"灌入"到os.environ;os.getenv()只是从os.environ里读取值。你可以把load_dotenv()理解为往仓库里进货,os.getenv()是从仓库里取货,少了前面那一步,后面取到的永远是None。

这也是最常见的初学者报错:明明.env文件写了值,os.getenv("DATABASE_URL")却打印出None。大概率是你没有调用load_dotenv(),或者调用的位置在读取操作之后。load_dotenv()必须在所有读取环境变量的代码之前执行,通常放在模块导入之后的第一行。

另外一个小建议:读取环境变量时尽量用os.getenv("KEY")而不是os.environ["KEY"]。两者的差别在于,key 不存在时os.getenv返回None,而os.environ["KEY"]会直接抛KeyError。对于配置项比较全的项目,抛异常其实是一种快速暴露问题的好方式,但如果你只是想给某个配置设个默认值,那么os.getenv("DEBUG", "false")这种写法就舒服得多。

2.3 还没完:find_dotenv 与路径搜索

load_dotenv()默认会在当前工作目录下找.env文件。这里有个暗坑:所谓"当前工作目录"不一定是你项目代码所在的目录,而是你执行python命令时所在的目录。

举个例子:你的项目在/home/user/myproject,但你习惯在/home/user下执行python myproject/main.py。这时load_dotenv()会在/home/user目录下找.env,找不到就什么都不做,你的环境变量自然全是None。

解决方式有两个。第一个是显式指定路径:

from dotenv import load_dotenv from pathlib import Path BASE_DIR = Path(__file__).resolve().parent load_dotenv(BASE_DIR / ".env")

用Path(__file__).resolve().parent拿到当前 py 文件所在目录,再拼上.env,这样无论你从哪里执行脚本都能正确找到配置文件。第二个是使用dotenv.find_dotenv(),它会自动向上级目录搜索,找到最近的.env文件:

from dotenv import load_dotenv, find_dotenv load_dotenv(find_dotenv())

我个人在开发小型脚本时倾向于第二种,代码简洁;在维护正式项目时倾向于第一种,显式路径一目了然,团队成员看到就知道配置文件在哪里。

3. 覆盖规则与查找路径:让配置文件按预期工作的关键

3.1 默认的"不覆盖"策略

这是 python-dotenv 最容易被误解的一个设计。默认情况下,load_dotenv()不会覆盖掉操作系统中已经存在的同名环境变量。假设你在终端里已经执行过export API_KEY=from_shell,那么即使.env文件里写了API_KEY=from_dotenv,最终程序读到的还是from_shell。

这个设计其实很合理:系统环境变量的优先级高于项目配置文件,这样可以保证在某些场景下,外部注入的配置不会被项目内的文件意外覆盖。但很多第一次接触的人会搞反,以为.env文件是"最权威"的,于是出现"我改了.env文件,程序跑的却还是旧配置"的困惑。

如果你确实需要让.env文件覆盖已有环境变量,可以显式传参:

load_dotenv(override=True)

override=True的含义是:以.env文件为准,把同名环境变量强制覆盖。真实项目中这个参数要谨慎使用,尤其是在多人协作的代码库里,如果某个人在启动脚本里加了override=True,可能会把 CI 系统注入的环境变量给冲掉,引发半夜被叫起来看告警的问题。

3.2 多个 .env 文件的加载顺序

python-dotenv 并没有"自动合并多个.env文件"的机制,它每次调用只处理一个文件。但你可以通过多次调用同一个函数来实现"叠加"的效果:

load_dotenv(".env.base") load_dotenv(".env.local")

按上面的顺序执行,.env.local里的值会被加载,但由于默认不覆盖,.env.base里已有的同名变量仍然保持原值。如果你希望.env.local覆盖.env.base,第二次调用时就要加override=True:

load_dotenv(".env.base") load_dotenv(".env.local", override=True)

这在多环境配置里是一个实用技巧。基础配置放在.env.base,针对本机的个性化覆盖放在.env.local,两者分工明确,既保证了通用配置的一致性,又允许开发者本地微调。

3.3 dotenv_values:只想读值不想污染环境变量

load_dotenv()的副作用是把所有变量写进os.environ。但有些场景你只想知道某个配置值是多少,并不想让这些值成为全局环境变量,比如你写了一个小工具,只想读一次配置用来拼个路径,工具本身不需要把配置暴露给子进程。

这时可以用dotenv_values(),它只解析.env文件,返回一个普通字典,不碰os.environ:

from dotenv import dotenv_values config = dotenv_values(".env") print(config.get("API_KEY"))

它的用法和load_dotenv完全对称,也支持encoding和路径参数。对比一下这两个方法的核心区别:

方法是否写入 os.environ适用场景
load_dotenv()是应用启动、脚本初始化,后续用os.getenv读取
dotenv_values()否一次性读取配置、临时工具、不想污染全局环境

3.4 解析规则:注释、空行与 export 前缀

.env文件不是随便写的,它有一套自己的解析规则。首先是注释,支持以#开头的行,解析时会自动忽略;空行也没问题,直接跳过。其次是export前缀,如果你从 shell 脚本里复制了一段配置过来,形如export API_KEY=xxx,dotenv 也能识别,它会自动去掉export再读取键值对。

值部分需要注意:双引号和单引号会被解析并移除,也就是说API_KEY="sk-123"读出来是sk-123而不是带引号的字符串。值里如果有#符号,比如一个备注性质的变量NOTE=hello#world,dotenv 会把#当作注释截断,读出来只有hello。真要存这种值,得用引号包起来:NOTE="hello#world"。

4. 多环境配置:开发、测试、生产如何共用一套代码

4.1 文件命名规范

项目跑起来之后,第一个绕不开的问题就是:开发、测试、生产三个环境的配置不一样,怎么管理?我见过不少团队把所有环境的值全部写进一个.env,然后用变量名的前缀区分,比如DEV_DATABASE_URL、PROD_DATABASE_URL,代码里再根据当前环境变量去选择。这种方案能用,但维护体验很差,改一个生产数据库地址得在一堆变量里翻半天。

更推荐的做法是按文件拆分:.env.development、.env.test、.env.production各管一套,启动时通过一个环境变量(比如APP_ENV)来决定加载哪个文件:

import os from dotenv import load_dotenv env = os.getenv("APP_ENV", "development") load_dotenv(f".env.{env}") load_dotenv(".env") # 公共配置最后再补一层

这套结构和"基础配置 + 环境覆盖"的思路是一致的。无环境变量时默认走 development,发布到服务器时设置APP_ENV=production,代码完全不用改。

4.2 用 dotenv 的命令行工具给脚本注入环境

python-dotenv 在安装时会顺带提供一个名为dotenv的命令行工具,支持直接用.env文件来运行其他程序:

dotenv -f .env.production run python manage.py migrate

这条命令的意思是:读取.env.production文件,把里面的变量注入当前环境,然后执行python manage.py migrate。这对于在服务器上跑一次性脚本特别方便,不需要手动复制一堆export命令,也保证了脚本运行时的配置和项目使用的配置完全一致。

如果你用的是 Docker Compose,也可以在docker-compose.yml里用env_file直接指定:

services: app: env_file: - .env - .env.local

Docker Compose 的env_file有一个特点:如果同一个变量在多个文件中出现,后面的文件会覆盖前面的文件。合理利用这个行为,你甚至可以在容器编排层面实现"基础配置 + 环境覆盖"的组合策略。

4.3 CI 里怎么处理 .env

很多人在本地开发时已经习惯了.env的存在,一到 CI(持续集成)环境就忘了这回事。CI 的运行环境通常是一个全新的容器,里面不会有你的.env文件。在 CI 中正确的做法是:把真正的密钥配置在 CI 平台的 Secret 或环境变量面板里,然后在 CI 脚本中直接读取系统环境变量,或者把这些值动态地写入.env文件再交给应用加载。

如果 CI 和本地都走同一套加载逻辑,那么代码本身不需要区分环境——本地靠.env文件,CI 靠平台注入的系统变量。这就是"配置与代码分离"在实践中的完整形态:代码不知道配置从哪来,它只知道读取环境变量。

5. 安全红线:哪些事 dotenv 管不了

5.1 .env 不是加密文件

这是我想反复强调的一点:.env文件里的内容是纯文本,看一眼就能读到。dotenv 只是把文件内容加载到环境变量里,它没有任何加密、混淆或者权限保护的功能。

很多人因为.env用起来太顺手,就误以为它有某种"隐藏配置"的能力,甚至有人把它当成储存生产密钥的安全方案。这是个危险的认知偏差。在本地开发时,.env文件属于你的个人环境,风险可控;但如果这份文件被提交进了代码仓库、被同步到了团队协作平台或者被打包进了发布产物,里面的每一个密钥就都暴露了。

所以,第一步永远是:把.env加入版本管理黑名单。在 Git 仓库里创建.gitignore文件,明确写上一行:

.env

然后确认这条规则生效。我见过不少"我以为已经忽略了"的案例——某同事在.gitignore里写的是.env.*,结果.env本身反而没被忽略,配置照样被提交上去了。这类细节需要反复核实。

5.2 .env.example 是团队的底线

既然真正的.env不能提交,新加入的同事怎么知道项目需要哪些环境变量?答案是维护一个.env.example文件:它列出所有必需的变量名,但值要么留空,要么填写无意义的示例值。

# .env.example DATABASE_URL=postgresql://user:pass@localhost:5432/app_db API_KEY=your_api_key_here DEBUG=false

新同事拿到代码后,执行cp .env.example .env,对照示例文件填入自己的本地值就能跑起来。团队里只要坚持维护.env.example,就能避免"新人不知道要配什么变量"的问题。我个人的习惯是:每次新增一个环境变量,同步更新.env.example,并且在代码评审时检查这一项,让这条规则变成团队约定。

5.3 生产环境的正确姿势

生产环境的密钥管理,dotenv 不是主角。服务器上的真实密钥应该通过云平台的密钥管理服务、容器平台的 Secret 机制或者专门的密钥管理系统来保存,应用启动时由平台注入为系统环境变量。你的代码只需要负责读取,不需要关心密钥存放在哪里。

那生产环境还需要 dotenv 吗?我的经验是:需要,但只用于非敏感的配置项。比如日志级别、超时时间、功能开关这类不是密钥的配置,用.env文件管理完全没有问题;而真正的数据库密码、支付密钥、云服务凭证,就应该走平台的密钥通道。把"敏感密钥"和"普通配置"分开管理,既是安全实践,也让运维排查问题时的排障路径更清晰。

6. 实测排坑:最常见的六个问题与排查思路

6.1 读取到 None 但 .env 里明明有值

这个问题的出现频率排在所有 dotenv 相关问题里的第一位。以前面说的路径问题为主因,但也有几种变体:一是load_dotenv()调用在os.getenv()之后,顺序反了自然读不到;二是文件确实存在,但文件名带了隐藏字符,比如不小心把文件命名成了.env.txt;三是代码在虚拟环境中运行,虚拟环境的系统变量把.env里的值覆盖了,而默认不覆盖策略让.env里的值"输"给了系统变量。

排查时我一般按这个顺序来:先打印os.environ里的目标变量确认是否为None,再用find_dotenv()检查能不能找到文件,最后看加载结果——load_dotenv()是有返回值的,成功加载返回True,没找到文件返回False。这个返回值是个很好的探针:

import os from dotenv import load_dotenv loaded = load_dotenv() print("dotenv loaded:", loaded) print("getenv:", os.getenv("API_KEY"))

如果loaded是False,基本可以断定是路径问题,参考前面 2.3 节的显式路径方案解决。

6.2 引号和特殊字符被解析成奇怪的样子

另一次排查经历是配置文件里写了一个很长的密码,密码本身带有#和引号,结果程序读到的密码和预期不一致,认证一直报错。原因是 dotenv 的解析规则:#开头表示注释,值中的#虽然不会把整行变成注释,但在一些版本和配置写法下容易出现截断或解析歧义;引号则会被自动移除。

最稳妥的做法是:凡是包含特殊字符的值,一律用双引号包起来,并且避免在值里使用$和反引号(它们在某些解析场景下会被当作特殊处理)。如果你的密码里不可避免地出现了引号字符本身,建议考虑用编码后的形式存储,或者在系统环境变量中直接设置而不是放进.env文件。

6.3 变量里引用其他变量

dotenv 支持简单的变量展开。比如你在.env里写了:

BASE_URL=http://example.com/api FULL_URL=${BASE_URL}/v1

解析之后FULL_URL的值会是http://example.com/api/v1。这在拼接 URL、文件路径时很实用,但要注意循环引用的问题:如果A引用了B,B又引用了A,解析结果会变成一个空值或异常。我一般只在层级简单、方向明确时才用变量展开,复杂的拼接逻辑更倾向于在代码里用字符串格式化完成,因为配置文件的职责是提供元数据,而不是实现逻辑。

6.4 中文和其他非 ASCII 字符乱码

.env文件里的值如果包含中文,部分环境下读取出来会出现乱码。这通常是因为默认编码不是 UTF-8。解决方案是在调用时显式指定编码:

load_dotenv(encoding="utf-8")

同时保证你的.env文件确实以 UTF-8 编码保存。很多编辑器默认保存为 UTF-8 没问题,但 Windows 环境下有些编辑器会写入带 BOM 的 UTF-8,BOM 头会让第一行变量的键名带上不可见字符,进而读取失败。这种情况的处理方式是把文件另存为"无 BOM 的 UTF-8",或者干脆统一用代码里的encoding="utf-8"加系统对 BOM 的兼容处理。

6.5 override 参数导致线上环境被本地文件覆盖

这是一个典型的"好心办坏事"案例。某次排查线上问题时发现,服务读取的配置不是运维平台注入的值,而是代码仓库里某份文件的值,原因就是有人为了方便本地调试,在代码里写了一行load_dotenv(override=True),交付时忘了去掉。结果生产环境启动时,这份文件只要存在,就会把平台设置的系统变量全部覆盖掉。

这类问题的教训是:override=True只应该出现在本地开发的临时脚本里,绝对不应该出现在正式应用的启动路径中。如果你需要区分本地和线上,可以用环境标志来控制是否启用 override:

import os from dotenv import load_dotenv APP_ENV = os.getenv("APP_ENV", "development") load_dotenv(override=(APP_ENV == "development"))

这样即使代码被部署到生产环境,只要APP_ENV不是development,override 就不会生效。

6.6 配置项类型问题:DEBUG 变量不是布尔值

最后一个坑来自类型而非加载本身。.env文件里的所有值本质上都是字符串,也就是说DEBUG=false在os.getenv("DEBUG")后得到的是字符串"false",而不是布尔值False。如果你直接写if os.getenv("DEBUG"):,那么"false"这个非空字符串会被判定为真,功能开关永远处于打开状态。

正确处理方式是把字符串转换成真正的布尔值再判断:

DEBUG = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")

同理,数字型配置也要记得转换。PORT=8080读出来是字符串,如果你要把它传给需要整数参数的函数,必须先int()。这类问题不会让程序报错,只会让程序跑出与预期相悖的结果,排查起来往往比显式异常更费时间。


经历过几轮项目切换和环境迁移之后,我现在对 dotenv 的使用已经形成了肌肉记忆:项目里永远有.env.example作为配置清单,正式的启动代码里永远显式指定.env路径,override=True永远只出现在本地临时脚本中。它不复杂,但恰恰是这些"不复杂"的小决策,决定了你的配置体系在关键时刻是帮你快速定位问题、还是让你在排查时无从下手。如果你也在用 dotenv,希望你从这篇文章里带走的不仅是 API 调用方式,还有那份对配置边界和安全底线的敏感度。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进