Python判断输入是否是数字:四种方法、边界与选型
数字校验这件事,很多人在写第一行业务代码时就撞上了:表单里用户填了个“12.5.3”,后端int()直接抛异常,接口 500;配置中心里某个值写成了INF,校验居然放行,结果算出来的价格是无穷大。Python 判断输入是否是数字,看似一句话的事,实际藏着字符集、进制、符号位、科学计数法、Unicode 数字、浮点边界这一连串深坑。我做过不少数据录入、命令行工具和配置解析的活儿,isdigit、isdecimal、isnumeric、try/except、正则、手写状态机这几套方案来来回回用了很多次,也踩过每一种的雷。这篇就把“Python 判断输入是否是数字”的四种主流方法彻底拆开讲,从它们的底层判定逻辑讲到适用边界,再配上可直接抄走的代码和一张横向选型表。不管你是刚学 Python 练手,还是要给线上接口加一层输入校验,看完都能对号入座找到合适的那一种,并且知道为什么不该用另外几种。
1. 先想清楚"数字"到底指什么,再谈方法选型
1.1 一个需求背后的三种语义
“判断输入是否是数字”这句话本身是有歧义的。我遇到过同一段代码被三个不同的人提出三种期待的情况:运营希望用户填“¥1,200”也算合法金额输入;算法同事希望“1e-5”能被识别;而数据库同事坚决认为只有纯整数才算合格的主键。这三种期待对应的是三种完全不同的语义,如果不先拆清楚,选哪种方法都是错的。
第一种是字符形态语义:这段字符长得像不像数字。它只关心字符本身,不管能不能参与运算。'123'(全角)在形态上确实是数字,'½'也是。str的is*系列方法走的就是这条路。
第二种是可解析语义:这段字符能不能被转换成程序里的数值对象。'1e5'算不上“长得像数字”,但它能被float()解析成 100000.0。'½'长得像数字,可float()拿它没办法。try/except和Decimal走的是这条路。
第三种是业务口径语义:在你的业务里,什么样的写法算合法。比如订单金额不允许负数、不允许科学计数法、最多两位小数。这类需求只有正则表达式和手写状态机能精确表达,因为规则是你自己定的。
选方法之前先回答一个问题:**这段字符串拿到手之后要干什么?**是直接参与算术运算,还是只是存进数据库当字符串,还是仅用于日志分类统计?目的不同,答案完全不同。
1.2 最容易被忽略的一个前置动作:去空白
不管最后选哪种方案,有一个动作我建议放在最前面做——去掉首尾空白。
raw = " 12.5 " s = raw.strip()原因很实在。' 12 '.isdecimal()返回False,但int(' 12 ')却返回12。也就是说,如果你用isdecimal()做前置校验挡住了带空格的值,后面的int()其实根本用不上;反过来,如果你依赖int()去容错,那你的校验逻辑和转换逻辑就是两套口径,早晚会打架。
我踩过一次典型的坑:某次数据导入脚本里,先用s.isdigit()过滤掉不合法行,再int(s)转换。结果一份 Excel 导出的 CSV 里,数字列全带前导空格,isdigit()全军覆没,脚本一行数据都没导进去,日志里还一片安静——因为我是把不合法的行continue掉的。后来加了strip(),问题立刻消失。
提示:
strip()默认会去掉各类空白字符,包括全角空格\u3000。但float()内部对 Unicode 空白的处理范围可能和你想象的略有出入,所以显式strip()比依赖转换函数自己容错更可控。
2. 方法一:is 系列字符串方法,最省事也最容易误判
2.1 isdigit、isdecimal、isnumeric 到底差在哪
Python 的str上有三个长得很像的方法:isdigit()、isdecimal()、isnumeric()。很多教程一笔带过说“都能判断数字”,实际上它们的判定范围是一个比一个宽的子集关系:isdecimal ⊆ isdigit ⊆ isnumeric。
我拿几个特殊字符实测过,结果很能说明问题:
| 字符 | isdecimal() | isdigit() | isnumeric() | int()能否转换 |
|---|---|---|---|---|
'7' | True | True | True | 可以 |
'123'(全角) | True | True | True | 可以 |
'²'(上标 2) | False | True | True | 不可以 |
'½'(二分之一) | False | False | True | 不可以 |
'一'(汉字数字) | False | False | True | 不可以 |
'Ⅷ'(罗马数字) | False | False | True | 不可以 |
'-5' | False | False | False | 可以 |
'3.14' | False | False | False | 可以 |
''(空串) | False | False | False | 不可以 |
这张表里的信息量比任何文字解释都大。isdecimal()只认“十进制数字字符”,也就是能构成十进制数的那些码位;isdigit()额外放行了上标、下标这类 digit 属性的字符;isnumeric()则把分数、罗马数字、汉字数字全都收编了。
注意看最后三行。这三个方法全都不认负号和小数点。原因很简单:-和.根本不是数字字符,它们是运算符和分隔符。所以任何带符号或小数的输入,用is*系列判断必然失败。这是最经典的误用场景,我在代码评审里见过太多次。
2.2 为什么'½'.isnumeric()为 True 却不能运算
这是个能让人懵很久的例子。'½'.isnumeric()返回True,但float('½')直接ValueError,int('½')同样报错。也就是说,isnumeric()告诉你“它是数字”,但 Python 的数值转换器根本没法把它变成数。
根子在 Unicode 的属性定义上。Unicode 给每个字符打了若干布尔属性,其中Numeric_Type分为 Decimal、Digit、Numeric 三档。isdecimal()检查 Decimal 档,isdigit()检查 Decimal 加 Digit,isnumeric()检查全部三档。但int()和float()的解析器只认Decimal_Type那一档,因为它必须是“位置记数法”的字符才能参与进制计算。罗马数字Ⅷ是一个整体符号,没法拆成“8 个 1 加 1 个 5”的位置表示,所以被排除在外。
理解这一点之后,选型逻辑就清楚了:如果你的下一步动作是数值转换,绝对不要用isnumeric()或isdigit(),只能用isdecimal(),或者干脆别用这一族。
2.3 这一族方法真正合适的场景
那is*系列是不是就没用了?不是。它在一种场景下非常顺手:你已经确认输入不含符号和小数点,只需要验证它是不是一串纯数字字符。
典型例子是验证码、身份证号、手机号、银行流水号、订单编号这类定长纯数字串。这些字段在业务上本来就不允许负号和小数点,用s.isdecimal()一行搞定,可读性极好,性能也是所有方案里最优的。
def is_pure_digits(s: str) -> bool: """只用于校验不含符号、小数点的纯数字串""" return s.isdecimal()另外它还有一个反直觉的优势:对空串返回False。正则如果写得不严谨,r'^\d*$'会匹配空串,isdecimal()天然免疫这个坑。所以如果你做的是批量过滤,is*系列是很好的第一道筛子。
3. 方法二:try 加 float/int 转换,工程上最常用的一招
3.1 转换法的本质是"以结果论英雄"
思路很直白:能不能转成数值对象,能转就是数字,转不了就不是。这种"先试后验"的风格在 Python 里有个专门的名字,叫 EAFP(Easier to Ask for Forgiveness than Permission),是社区里非常推荐的写法。
def is_number_by_cast(s: str) -> bool: try: float(s) return True except (TypeError, ValueError): return False为什么用float()而不是int()?因为int('3.14')会抛ValueError。int()只接受纯整数形式的字符串,连'12.0'都不行。你要覆盖整数加小数两种输入,必须用float()。这是第二个高频误用点:很多人写try: int(s),然后发现用户输3.14就报错,还以为是用户输错了。
float()能接受的输入比大多数人以为的宽。我列一下实测通过的写法:'12'、'-12'、'+12'、'3.14'、'.5'、'5.'、'1e5'、'1E-5'、' 12 '、'1_000'、'123'(全角)、'١٢٣'(阿拉伯-印度数字)。
3.2 三个必须知道的宽松点:inf、nan、下划线
float()的宽松是有代价的,它放行了三个在业务上通常不该算数字的东西。
第一个是无穷大。float('inf')、float('infinity')、float('-Infinity')全部合法,返回inf。更隐蔽的是溢出:float('1e400')不报错,直接返回inf。我曾经在一个计算运费的模块里踩过这个坑,某个渠道配置里写了个天文数字,结果运费算出来是inf,一路传到数据库才炸。
第二个是nan。float('nan')合法返回nan,而且nan != nan恒为真,意味着你后面写if value == value之类的判空逻辑会完全失效。
第三个是下划线分隔符。从 Python 3.6 开始支持数字字面量下划线,float('1_000')返回1000.0,int('1_000_000')返回1000000。这个特性对写代码很方便,但作为用户输入的校验口径就太松了——用户不会没事敲下划线,能出现下划线大概率是脏数据。
所以生产环境里,我通常会在转换法外面加一层显式排除:
import math def is_number_strict_float(s: str) -> bool: if not isinstance(s, str): return False s = s.strip() if not s or '_' in s: return False try: v = float(s) except ValueError: return False return math.isfinite(v) # 同时排除 inf 和 nanmath.isfinite()一次干掉inf、-inf、nan三类特殊情况,比逐个math.isnan()加math.isinf()判断干净得多。
3.3 想要小数精度,就把 float 换成 Decimal
float是二进制浮点,0.1 + 0.2 != 0.3这个经典问题在金额场景里是致命的。如果这个输入后续要参与钱的计算,校验阶段就该用Decimal而不是float。
from decimal import Decimal, InvalidOperation def is_number_decimal(s: str) -> bool: s = s.strip() if not s or '_' in s: return False try: v = Decimal(s) except InvalidOperation: return False return v.is_finite()Decimal的异常类型是InvalidOperation,不是ValueError,这点和float()不同,写except ValueError是接不住的。而且Decimal('nan')、Decimal('Infinity')同样合法,必须靠is_finite()拦住。
Decimal的解析口径比float更严一些,Decimal('1e400')不会变成inf,而是老老实实给你一个巨大的数。在金额、税率、汇率这类场景,这个差别就是事故和正常的差别。
4. 方法三:正则表达式,校验口径由你自己定
4.1 一条覆盖符号、小数、科学计数法的式子
当业务规则明确到"允许负号、允许小数、允许科学计数法、不允许前导空格"这种程度时,正则是最直接的表达方式。
import re # 允许可选正负号;整数部分 + 可选小数部分,或纯小数部分;可选指数部分 NUM_PATTERN = re.compile(r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?', re.ASCII) def is_number_regex(s: str) -> bool: return NUM_PATTERN.fullmatch(s) is not None拆开看这条式子的每一段:
[+-]?— 可选的一个正负号,注意是?不是*,不允许--5这种。(?:\d+(?:\.\d*)?|\.\d+)— 主体分两支。前一支是"至少一位数字,可选小数点加任意位数",覆盖12、12.、12.5;后一支是"点打头加至少一位数字",覆盖.5。(?:[eE][+-]?\d+)?— 可选的指数部分,1e5、1E-5、1e+5都能过。
re.ASCII这个标志很关键。Python 3 的str正则里,\d默认匹配 Unicode 十进制数字,也就是说'123'能被\d+匹配上。加上re.ASCII之后,\d退化为[0-9],只认半角。需不需要加,取决于你的业务接不接受全角输入。做后台管理系统我一般不加,用户从中文输入法切过来很容易打出全角;做接口参数校验我一般加上,因为前后端约定就是半角。
4.2 用 fullmatch 而不是 match 或 search
这是个非常容易翻车的细节。re.match只从字符串开头匹配,不要求匹配到结尾。re.match(r'\d+', '12abc')是能匹配成功的,返回的Match对象是真的,但'12abc'显然不是数字。
正确做法是用fullmatch,它要求整个字符串都被模式吃掉。Python 3.4 之后就有这个方法了,比在模式里手写^...$更不容易出错($有个小毛病,它能匹配末尾换行之前的位置,'12\n'在某些模式写法下会被误判)。
如果非要兼容老写法用match,请务必在模式两端加上\Z而不是$:
NUM_PATTERN_OLD = re.compile(r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\Z', re.ASCII)\Z是严格匹配字符串末尾,不给换行留后门。这个差别在处理从文件读进来的行时特别重要,因为readlines()保留的每行末尾都带\n。
4.3 几种常见业务口径的正则速查
实际项目里几乎不会用到上面那条完整的式子,更多时候是各种收窄版本。我把常用的几条整理成表,直接拿去改就行:
| 业务口径 | 正则 | 说明 |
|---|---|---|
| 正整数 | [1-9]\d* | 不允许 0 开头,不允许 0 本身 |
| 非负整数(含 0) | (?:0|[1-9]\d*) | 拦住007这种写法 |
| 最多两位小数的金额 | -?\d+(?:\.\d{1,2})? | 常用于订单、账单 |
| 必带两位小数的金额 | -?\d+\.\d{2} | 报表、对账场景 |
| 百分比 0 到 100 | (?:100(?:\.0{1,2})?|(?:\d{1,2})(?:\.\d{1,2})? ) | 注意去掉末尾空格后再用 |
| 身份证号(18 位) | \d{17}[\dXx] | 最后一位可能是 X |
| 手机号(11 位) | 1[3-9]\d{9} | 半角数字 |
那张表里"百分比"那条我故意留了个空格提醒你,实际写的时候要去掉。这类正则我一个一个在调试器里试过,别看只有几个字符,0和00的区别、.5和0.5的区别,每一个都能让线上出问题。
正则在四种方法里是可控性最强的一个,规则怎么定完全由你说了算,而且和前端、文档、接口协议之间可以直接对齐同一份规则文本。缺点是模式一复杂可读性就崩,接手的人得盯着看半天。
5. 方法四:手写逐字符状态机,可控性最强也最啰嗦
5.1 为什么不直接用正则,还要自己写
正则好用但有两个实际问题:一是超长正则在某些输入下可能出现灾难性回溯(写得不严谨的时候),二是模式的可读性对团队不友好,改一个字符都可能影响全局。
手写状态机的思路是:从左到右扫一遍字符,用一个状态变量记录"我现在走到哪一步了",每碰到一个字符就转移一次状态。写出来比正则长,但每一步都赤裸裸摆在代码里,谁都能读懂,改起来也不会误伤别处。
def is_number_fsm(s: str) -> bool: """手写状态机:允许正负号、小数、科学计数法,不允许空白""" if not s: return False i, n = 0, len(s) # 1. 处理符号位 if s[i] in '+-': i += 1 if i >= n: return False # 2. 整数部分 int_digits = 0 while i < n and s[i].isdigit(): int_digits += 1 i += 1 # 3. 小数部分 frac_digits = 0 if i < n and s[i] == '.': i += 1 while i < n and s[i].isdigit(): frac_digits += 1 i += 1 # 整数和小数部分至少要有一个数字 if int_digits == 0 and frac_digits == 0: return False # 4. 指数部分 if i < n and s[i] in 'eE': i += 1 if i < n and s[i] in '+-': i += 1 exp_digits = 0 while i < n and s[i].isdigit(): exp_digits += 1 i += 1 if exp_digits == 0: return False # 5. 必须全部消费完 return i == n这段代码的核心是最后那个return i == n。它等价于正则里的fullmatch,确保没有多余字符被漏掉。很多人手写的时候忘了这句,结果'12abc'也能过。
5.2 状态机最容易漏的三处判定
上面对着写出来的代码,有三处我一开始都写错过,值得单独点出来:
第一处:if i >= n: return False这个符号位之后的检查。如果只写符号而没有数字,比如输入是'-'或者'+',符号位吃掉了唯一一个字符,i就等于n了。后面循环全都不进,int_digits和frac_digits都是 0,虽然最终会被int_digits == 0 and frac_digits == 0拦住,但显式加一句更清晰,也避免后续扩展时出错。
第二处:整数部分和小数部分的"至少有一个"是or不是and。写成and的话,'.5'(整数位 0 个)和'12.'(小数位 0 个)都会被拒绝,但这两个在float()那里都是合法的。用or才能对上float的口径。
第三处:指数部分后面必须至少跟一位数字。'1e'和'1e+'都是不合法的,靠exp_digits == 0拦住。
5.3 我实测下来的一个小优化
纯手写状态机在处理大量输入时,瓶颈往往在s[i].isdigit()这个调用上——它是 Unicode 感知的,比直接比较 ASCII 码范围慢。
如果你的输入确定只可能是半角数字,把它换成'0' <= s[i] <= '9'能快一截。我在一个解析百万行日志的小工具上做过对比,切过去之后整体耗时降了大概两成。当然这种优化要建立在输入来源可控的前提下,如果用户输入的可能是全角数字,换了反而会漏判。
6. 四种方法横向对比,怎么选不出错
6.1 三维对比表
把四种方法放在一起,从严格度、可读性、性能三个维度拉一张表,选型时对着看就行:
| 方法 | 严格度 | 可读性 | 相对性能 | 能否识别负数与小数 | 典型适用场景 |
|---|---|---|---|---|---|
isdecimal() | 只认十进制数字字符 | 高 | 最高 | 否 | 纯数字串、编号、验证码 |
try + float() | 宽松,放行 inf/nan/下划线 | 高 | 中等(异常路径慢) | 是 | 通用输入校验 |
try + Decimal() | 中等,精度可控 | 中 | 中偏低 | 是 | 金额、汇率、税率 |
正则fullmatch | 完全自定义 | 中(模式简单时) | 高(预编译后) | 是 | 业务规则明确、需对外对齐口径 |
| 手写状态机 | 完全自定义 | 中 | 高 | 是 | 需精细控制、不想依赖正则 |
性能这块补充一点观察:isdecimal()因为直接在 C 层做字符属性查表,绝对是第一梯队;预编译的正则紧随其后,差距大概在一个数量级以内;try + float()在成功路径上其实不慢,但失败路径要走异常栈回溯,代价明显,如果你的场景里绝大部分输入都是非法值(比如过滤海量脏数据),用异常做流程控制会让整体耗时涨上去。这种时候先用isdecimal()或正则做初筛,再对初筛通过的做转换,是更稳的组合拳。
6.2 我实际项目里的选型习惯
说几个真实场景下我会怎么选,比抽象规则好用。
场景一:命令行工具读用户输入。我会用try + float(),并且专门处理KeyboardInterrupt,别让它被except Exception吞掉。
while True: raw = input('请输入一个数字:') try: value = float(raw) if not math.isfinite(value): print('请输入有限的数值') continue break except ValueError: print('这不是一个合法的数字,请重试')注意这里我直接用input()的返回值做转换,而不是先校验再转换。省掉一次重复判断,也避免了两套口径不一致。
场景二:Web 接口的 JSON 参数校验。这类场景通常有明确的契约文档,我会用正则,并且把模式定义成模块级常量,让文档和代码能对得上。接口参数要求“最多两位小数的正数”,那就一条^\d+(\.\d{1,2})?$,多余的一律 400。
场景三:Excel、CSV 批量入库。这种场景数据量可能几十万行,而且脏数据比例高,我会用组合方案:
def normalize_number(cell) -> float | None: if cell is None: return None if isinstance(cell, (int, float)): return float(cell) if math.isfinite(cell) else None s = str(cell).strip().replace(',', '') # 顺手去掉千分位 if not s: return None try: v = float(s) except ValueError: return None return v if math.isfinite(v) else None开头那个isinstance判断不能省。很多表格库在读取纯数字单元格时直接给你float或int对象,不是字符串,这时候再去.strip()就 AttributeError 了。这个坑我在两个不同的项目里各踩过一次。
7. 输入校验里真正会咬人的几个边界
7.1 全角字符与不可见空白
中文输入法下打出来的数字是全角的,'123'。这种输入在后台管理系统里极其常见,尤其是年龄、数量这类小字段。
好消息是,int()和float()都能处理全角十进制数字,int('123')返回123。坏消息是,isdecimal()对全角返回True,但很多人不加re.ASCII的正则也会放行它,而前端传给后端时如果中间有编码转换,可能就变成乱码了。
另一种更隐蔽的是不可见字符。从某些系统复制粘贴过来的数字,末尾可能带着零宽空格\u200b,strip()是去不掉它的(strip()只处理空白字符,零宽空格不算空白)。这种情况下float()也会失败,报错信息还特别迷惑,因为打印出来看着完全正常。
处理办法是在清洗阶段显式替换掉这些字符:
_INVISIBLE = dict.fromkeys(map(ord, '\u200b\u200c\u200d\ufeff'), None) def clean_input(s: str) -> str: return s.translate(_INVISIBLE).strip()dict.fromkeys加str.translate是批量删除指定字符最快的写法,比连着写好几个replace清晰。
7.2 千分位、货币符号和括号负数
业务数据里经常出现'1,234.56'、'¥888'、'(500)'这几种写法。最后那个括号是财务里表示负数的常见约定,(500)意思是 -500。
标准的转换函数一个都吃不下去。我的处理习惯是先做规整,再做校验,两步分开写,别糅在一起:
def normalize_money(s: str) -> str: s = s.strip() neg = False if s.startswith('(') and s.endswith(')'): neg, s = True, s[1:-1] s = s.replace(',', '').replace(',', '').replace('¥', '').replace('$', '').strip() return ('-' + s) if neg else s规整完之后再走Decimal转换。这样做的好处是,如果规整后的结果还是转不了,你能明确知道是数据格式有问题,而不是被一个混合的逻辑绕晕。另外要注意中文逗号,和半角逗号,都要处理,我见过从 Word 文档复制出来的表格里混着中文标点。
注意:千分位剔除只能做在“确认是金额字段”的场景。如果是编号类字段,把逗号去掉可能把两条不同记录混成一条,那问题就大了。
7.3 浮点边界:看起来是数字,但算出来不对
还有一种“假数字”值得单独说。0.1 + 0.2在 Python 里不等于0.3,这不是 bug 是二进制浮点的固有特性。如果用户输入的是0.1和0.2,你把它们转成float相加,再和用户输入的0.3比较,结果是False。
这个坑和“判断是否数字”看起来无关,实际上是同一个流程里紧挨着的两步。我的建议是:只要涉及金额、比例、税额这类需要精确比较的输入,从校验到存储全程用Decimal,不要让float掺进来。比较的时候用Decimal('0.1') + Decimal('0.2') == Decimal('0.3'),返回True。
对应的校验函数也换成Decimal版本,前面 3.3 节给过代码。多写这几行,能省掉后面无数的对账麻烦。
最后再分享一个我在代码评审里常用的检查清单,看到“判断是不是数字”的代码时,我会依次问四个问题:输入有没有先strip()?inf和nan挡住了没有?校验和转换是不是同一套口径?负数和小数点考虑到了没有?这四个问题问下来,八成的问题能在上线前抓住。