Python判断输入是否是数字:四种方法、边界与选型

发布时间:2026/11/3 23:51:13
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'TrueTrueTrue可以
'123'(全角)TrueTrueTrue可以
'²'(上标 2)FalseTrueTrue不可以
'½'(二分之一)FalseFalseTrue不可以
'一'(汉字数字)FalseFalseTrue不可以
'Ⅷ'(罗马数字)FalseFalseTrue不可以
'-5'FalseFalseFalse可以
'3.14'FalseFalseFalse可以
''(空串)FalseFalseFalse不可以

这张表里的信息量比任何文字解释都大。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 和 nan

math.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挡住了没有?校验和转换是不是同一套口径?负数和小数点考虑到了没有?这四个问题问下来,八成的问题能在上线前抓住。