资讯详情

Go fmt格式化输出全解析:从Printf到Sprintf与时间格式化

📅 2026/9/17 22:11:31 | 华诺云谱 👁 阅读
Go fmt格式化输出全解析:从Printf到Sprintf与时间格式化
如果你用Go写过几行代码大概率每天都在跟fmt.Println打交道。但说句实在话很多人的fmt使用水平停留在“能打印就行”的阶段要么在日志里拼接字符串拼到怀疑人生要么打印一个结构体出来全是{key {key value}}看得一头雾水要么格式化时间还要去搜索“Go time 2006-01-02 是什么意思”。其实fmt的格式化输出是Go标准库里最值得先吃透的模块之一它不止是调试工具更是Sprintf拼字符串、Errorf包装错误、Fprintf写文件、日志系统标准化的地基。这篇文章我会从打印三兄弟Print、Println、Printf讲起把格式化动词、宽度精度控制、Sprintf这类进阶玩法连带着“时间格式化为什么是 20060102”这个经典话题通通用大白话拆一遍。适合刚入门 Go、看官方文档有点懵的同学也适合写了两三年 Go 但格式化一直靠肌肉记忆、遇到%v和%#v才停下来搜一下的朋友。1. 打印三兄弟Print、Println、Printf怎么选1.1 三个打印函数的核心区别不少新手最初会困惑Go 为啥要给三个打印函数直接一个不行吗实际上这三个函数对应了三种完全不同的场景用错了就会出现“多了一堆空格”或者“怎么打出来没换行”这类小问题。fmt.Println(args...)最无脑的调试打印多个参数之间自动补一个空格输出完了自动换行。比如fmt.Println(hello, world)输出的是hello world注意中间那个空格是它自己加的。fmt.Print(args...)只负责把参数一个接一个输出去不加空格也不换行。fmt.Print(hello, world)输出是helloworld。这个用得相对少一般是在拼凑输出、且你自己已经处理好了空格和换行时才用。fmt.Printf(format, args...)按照你写的格式字符串来输出不会自动换行。这是格式化输出的绝对主力也是本文的核心。举个例子直观感受一下package main import fmt func main() { name : 张三 age : 18 fmt.Println(姓名, name, 年龄, age) fmt.Print(姓名, name, 年龄, age, \n) fmt.Printf(姓名%s 年龄%d\n, name, age) }上面三行输出的内容基本等价但你可以看到Println最省事Printf表达了最清晰的结构。实际写代码时临时调试我基本都用Println但一旦涉及格式化、补零、对齐、拼接动态字段就会立刻切到Printf。1.2 Println 的隐藏行为参数之间自动加空格Println自动加空格这个设计很多人用了一年也没意识到它有多贴心。比如你要输出name张三 age18直接写fmt.Println(name, name, age, age)输出是name 张三 age 18注意后面多了一个空格因为Println在每个参数之间都塞了一个空格。这就是为什么你在别人代码里经常看到fmt.Println(name, name)输出的name 张三看着有点丑。要避免这个空格有两个办法一是自己把字符串拼好再传进去比如fmt.Println(namename, agestrconv.Itoa(age))二是直接用Printf格式化干净利落。我个人在调试多字段时几乎不用Println因为容易把空格和字段关系搞乱只有纯文本临时输出才用。1.3 打印家族的另一面Fprint 和 Sprint很多人不知道fmt还有一组Sprint和Fprint系列。Sprintf是“把格式化结果作为字符串返回”Fprintf是“把格式化结果写入任意一个io.Writer”。后面第 4 章会专门讲它们这里先建立整体认知fmt.Sprintf(fmtStr, args...)返回字符串不输出到屏幕。fmt.Printf(fmtStr, args...)等价于fmt.Fprintf(os.Stdout, fmtStr, args...)。fmt.Sprintf最常用于构造日志消息、错误信息、SQL 片段里的动态条件。Println的输出目标固定是标准输出而Fprintf可以写文件、写网络连接、写bytes.Buffer灵活得多。这是fmt包真正的威力所在理解这点之后再去看日志库、错误处理库的实现会豁然开朗。2. 格式化动词先记住这几个就够用2.1 最常用的8个动词格式化动词就是Printf格式字符串里的%v、%d、%s这些东西。它们决定参数以什么形式呈现。我整理了一份高频动词速查表动词作用示例输出参数为 18%v默认格式万能兜底18%T打印变量类型int%d十进制整数18%s字符串hello%t布尔值true%f浮点数默认6位小数3.141593%q带双引号的字符串hello%x十六进制12%c字符按 Unicode 码点输出A参数 65%p指针地址0xc0000ba000初学者最容易犯的错是拿到什么类型都用%v。%v确实什么都能打但当你需要数字补零、限制小数位、或者对齐表格时就无能为力了。有个简单的经验调试用%v快速看值正式代码里写日志尽量用明确类型的动词可读性和可检索性都会好很多。再补一句%d可以直接打印int、int8、int64、uint等所有整数类型不需要手动转成int。%f打印浮点数时默认 6 位小数想精确控制位数看下一章的精度部分。2.2 调试时更能打的 %v 和 %#v如果%v是“能看”那%v和%#v就是“看得舒服”和“看得彻底”。type User struct { Name string Age int } u : User{Name: 张三, Age: 18} fmt.Printf(%v\n, u) // {张三 18} fmt.Printf(%v\n, u) // {Name:张三 Age:18} fmt.Printf(%#v\n, u) // main.User{Name:张三, Age:18}%v只输出值字段名全部丢掉了多个字段时你只能靠猜%v会把字段名也打出来日常调试足够用%#v直接输出一段几乎可以粘贴回代码里的语法片段不仅包含字段名还包含类型名main.User。我一般建议看到结构体打印%v需要向别人贴日志或者复现问题时%#v。这两个动词能帮你少敲很多代码。2.3 参数数量和格式串不匹配会怎样Printf格式字符串里的动词数量和实际传入参数数量不一致时fmt 不会 panic而是会在输出里直接插入警告信息。例如fmt.Printf(姓名%s 年龄%d\n, name) // 年龄%!d(MISSING) fmt.Printf(姓名%s 年龄%s\n, name, 123) // 年龄%!s(int123)第一个示例因为少了参数输出年龄%!d(MISSING)第二个示例因为类型对不上输出年龄%!s(int123)而且这个%!s(int123)会把实际类型和值都显示出来。这个特性在调试时反而很好用因为泄露的错误信息足够丰富。但一定要警惕它在正式环境不会报错只会产生一串难看的输出而且很难通过常规手段立刻捕捉到。如果你在日志里看到%!开头的内容八成就是类型或参数数量写错了。3. 宽度、精度与对齐让输出不再乱糟糟3.1 宽度控制右侧补空格和左侧补空格格式化输出不仅关心“打印什么”还关心“打印多宽”。宽度控制最常见于表格对齐、日志前缀、进度条这类场景。写法是%后面跟一个数字%5d宽度至少 5 个字符默认右对齐不够就在左边补空格。%-5d左对齐不够就在右边补空格。%05d用 0 填充而不是空格常用于序号、编号、日期补零。fmt.Printf(|%5d|\n, 18) // | 18| fmt.Printf(|%-5d|\n, 18) // |18 | fmt.Printf(|%05d|\n, 18) // |00018|宽度参数不只适用于整数字符串一样管用%10s会把字符串扩展到至少 10 个字符宽度。实战里我做对齐打印时最常用的是%-10s让左对齐的字符串列稳定占位这样多行数据可以像表格一样整齐。3.2 精度控制浮点数别被打脸精度控制最常见的就是浮点数保留几位小数写法是.加数字比如%.2f表示保留两位小数。更完整的组合是“宽度.精度”比如%8.2f表示总宽度至少 8 个字符其中小数部分占 2 位右对齐。pi : 3.1415926535 fmt.Printf(%.2f\n, pi) // 3.14 fmt.Printf(%10.2f\n, pi) // 3.14 fmt.Printf(%08.2f\n, pi) // 000003.14这里有个我踩过很多次的坑浮点数的精度和你看到的十进制值不一致是 IEEE 754 的锅不是 fmt 的锅。比如fmt.Printf(%.2f\n, 1.005) // 输出 1.00而不是 1.01为什么会这样因为浮点数在内存里是 1.00499999999999989 这种东西四舍五入时自然就变成了 1.00。如果你在写跟金额相关的业务想都不想直接用%f系列格式化是大忌。我的习惯是金额用整数“分”来存需要展示时再手动除 100 并补零如果一定要用浮点也至少用math.Round或者strconv.FormatFloat明确舍入模式别把展示问题和精度问题混在一起。3.3 中文对齐的视觉坑宽度控制里有一个隐藏的视觉问题中文字符在终端里占两个英文字符的显示宽度但 Go 的fmt宽度计算只按字符个数rune算不按视觉宽度算。打个比方fmt.Printf(%-10s|%d\n, 姓名, 18) fmt.Printf(%-10s|%d\n, Alice, 20)你以为第一行的姓名占 2 个字符宽补 8 个空格和第二行的Alice补 5 个空格能对齐但实际屏幕上第一行会比第二行短因为姓名在终端里占了 4 个英文宽度计算时却只按 2 个算。这是非常常见的“明明对齐了却歪了”的原因。要真正按显示宽度对齐一个简单方案是引入text/tabwriter让它按\t分隔符动态算列宽另一个方案是自己写一个按 rune 显示宽度计算空格数的函数网上有很多现成实现。实在懒得处理就用\t加固定宽度的组合虽然不完美但在多数终端里比裸%s对齐效果好得多。3.4 动态宽度到底填多宽由参数说了算有时候宽度不是固定的而是运行时算出来的比如根据最长内容动态决定。这时候用%*d语法*表示从参数列表里取宽度width : 8 fmt.Printf(%*d\n, width, 123) // 输出 123宽度8右对齐 fmt.Printf(%-*d\n, width, 123) // 输出123 左对齐%*.*f则可以把宽度和小数位数都变成参数。这个技巧在生成报表、打印动态对齐表格时很好用能少写很多手动的strings.Repeat。4. Sprintf、Errorf、Fprintf让格式化输出真正进入业务逻辑4.1 Sprintf 拼字符串告别手写 号很多人刚写 Go 时拼接字符串都是用户名: username 年龄: strconv.Itoa(age)又长又容易漏。用fmt.Sprintf一下子清爽了msg : fmt.Sprintf(用户 %s%d 岁登录成功, username, age)Sprintf的返回值就是标准字符串可以用在日志、错误、组装通知消息等一切需要动态文本的场合。不过要注意如果只是纯性能敏感的大量字符串拼接Sprintf的反射开销比strings.Builder高但在绝大多数业务代码中微乎其微。我自己的判断标准是循环外拼几个字符串用Sprintf循环内高频拼接用strings.Builder。4.2 Errorf 与 %w包装错误时别把链弄丢了fmt.Errorf是构造错误信息的一把好手Go 1.13 以后还支持%w动词用来包装原始错误if err ! nil { return fmt.Errorf(打开配置文件失败: %w, err) }用%w包装后上层可以用errors.Is判断错误链里是否包含目标错误也可以用errors.As提取特定类型。很多人图省事会用%v传错误return fmt.Errorf(打开配置文件失败: %v, err) // 糟糕错误链断了这样错误信息看起来一样但实际上已经丢失了原始错误类型后续想用errors.Is判断是哪个底层错误结果永远为 false。这是我在 Code Review 时经常指出来的一处。什么时候用%w什么时候用%v我的经验是只要这个错误会被上层继续流转、需要判断类型或继续包装就用%w如果错误信息是给最终用户或日志展示的终点才用%v或者%s提前把错误内容“拍扁”。4.3 Fprintf 写文件标准输出之外的另一面Fprintf的第一个参数是io.Writer也就是说只要实现了Write方法的都可以往里写。最典型案例是写文件f, err : os.Create(report.txt) if err ! nil { log.Fatal(err) } defer f.Close() fmt.Fprintf(f, 用户ID: %d, 用户名: %s\n, 1, 张三)也可以写进bytes.Buffer然后拿去拼 HTTP 响应体、生成 CSVvar buf bytes.Buffer fmt.Fprintf(buf, %s,%d\n, 张三, 18) body : buf.String()Fprintf的价值在于把“格式化和输出目标”解耦了同一套格式化逻辑终端、文件、网络、内存 buffer 都能用。这也是fmt.Println本质上等于fmt.Fprintf(os.Stdout, ...)的原因。5. 时间格式化为什么是 200601025.1 不是魔法数字而是参考时间很多 Go 新手看到time.Now().Format(2006-01-02 15:04:05)时一脸懵心想这是什么神仙占位符。其实这不是随机写的一串数字而是 Go 团队故意设计的“参考时间”。2006-01-02 15:04:05这个字符串本身就是一个具体的时间2006 年 1 月 2 日 15 点 04 分 05 秒。用它做模板的好处是所见即所得——你写的布局长什么样输出就长什么样。对比其他语言的YYYY-MM-DD HH:mm:ssGo 把字母型占位符换成了一个真实日期数字之间一一对应Go 写法含义示例输出2006四位数年份202306两位数年份2301两位数月份081不补零的月份802两位数日期052不补零的日期51524 小时制小时150312 小时制小时0304分钟0405秒05PM上下午标记PMMon星期几缩写MonMonday星期几全称MondayMST时区缩写UTC-07:00时区偏移08:00可以这样记忆这串参考时间本质上是 1 月 2 日 15 点 04 分 05 秒年份 2006 年星期一是 Monday时区 MST合起来数字序列正好是 1 到 7。当初设计时选这个时间也是为了好记因为 2006-01-02 15:04:05 刚好是一个“看起来像递增数字序列”的时间点。5.2 常用时间格式模板速查实际业务中我经常用的模板就几个建议直接抄time.Now().Format(2006-01-02 15:04:05) // 2023-08-01 12:34:56最常见 time.Now().Format(2006-01-02) // 2023-08-01纯日期 time.Now().Format(15:04:05) // 12:34:56纯时间 time.Now().Format(20060102) // 20230801适合文件名 time.Now().Format(time.RFC3339) // 2023-08-01T12:34:5608:00 time.Now().Format(time.RFC3339Nano) // 带纳秒的RFC3339 time.Now().Format(2006年01月02日 15:04:05) // 2023年08月01日 12:34:56注意Format格式串里的普通字符会原样输出比如中间的分隔线-、冒号:、空格、中文都是原样保留的。这意味着你完全可以根据业务需要自定义任何格式只要保证2006、01、02、15、04、05这些关键数字不被误改就行。5.3 Parse 和 ParseInLocation 的时区坑有格式化就有解析。字符串转时间时用的是time.Parse和time.ParseInLocationt, err : time.Parse(2006-01-02 15:04:05, 2023-08-01 12:34:56)新手特别容易踩两个坑。第一time.Parse默认解析结果是 UTC 时区。如果你拿到的时间字符串本身是本地时间直接Parse后再打印会发现自己“慢了 8 小时”。解决办法是显式用time.ParseInLocation指定本地时区t, err : time.ParseInLocation(2006-01-02 15:04:05, 2023-08-01 12:34:56, time.Local)第二格式串必须和输入字符串完全对齐。2023-8-1 12:34:56这种不补零的输入用2006-01-02 15:04:05去解析会直接报错。这时候要把模板换成2006-1-2 15:04:05让月日不补零。见过太多人在这个细节上翻车建议先在脑内“用模板输出一遍”再写解析代码。6. 格式化输出的坑位图鉴6.1 用 %q 看清字符串里隐藏的字符%s打印字符串是“原样输出”如果字符串里带了换行、制表符、\r这种不可见字符终端上很难看出来。比如你从文件或接口读到了一段带\r\n的文本用%s打印会看到一行奇怪的回车换成%q就会显示成带转义的形式fmt.Printf(%s\n, hello\nworld) // hello // world fmt.Printf(%q\n, hello\nworld) // hello\nworld%q会输出带双引号的 Go 字符串字面量把转义符全部显式列出来。排查外部数据里的非法字符时%q是我最常用的工具之一。对付字符串中还夹杂%的情况别忘了用%%转义fmt.Printf(进度: %d%%\n, 100) // 进度: 100%6.2 小心 Error 方法把自己打印死当你用fmt打印一个error接口值时fmt 会调用它的Error()方法来获取字符串。如果某个类型自己写的Error()方法内部又去fmt.Sprintf打印自己就会形成无限递归直接栈溢出崩溃。看这段反面教材type MyError struct { Msg string } func (e *MyError) Error() string { return fmt.Sprintf(MyError: %v, e) // 灾难先调 Error再调 String死循环 }fmt.Sprintf打印e时发现它实现error又去调Error()于是永远递归下去。正确的写法是访问e.Msg字段或者*e。我再提醒一句给结构体实现String()或Error()方法时千万别在里面打印自身这个 bug 不好肉眼发现但一运行就炸。靠fmt打印String()的返回值而不是强制递归调用字符串方法Go 的打印流程是先看Formatter再看error再看Stringer所以自定义String()方法里打印自己一样会炸。6.3 从 Python 转过来的人容易踩的槽热词榜里挂着“python格式化输出”说明不少人是带着 Python 的习惯来看 Go 的。Python 的%语法确实和 Go 很像%s %d % (name, age)对应fmt.Sprintf(%s %d, name, age)几乎可以无缝迁移。但有几个差异容易踩Python 的.format和 f-string 是运行时插值Go 没有内置 f-string字符串前加f是语法错误。想要“模板中直接嵌变量”只能靠Sprintf。Python 的格式化说明符如{:.2f}.format(x)和 Go 的%.2f思路一致但 Go 不支持 Python 那种通过位置和关键字灵活引用的{0}、{name}写法参数必须按顺序对应。Python 的%s几乎可以接任何类型Go 里%s明确只输出字符串内部会走Stringer接口传数字会打出%!s(int123)所以从 Python 转来的同学最需要养成“按类型选动词”的习惯。说实话我刚从 Python 转 Go 时也觉得 f-string 真香后来写多了反而喜欢 Go 的显式风格——它逼着你想清楚每个字段的类型这在长期维护的日志代码里非常值钱。6.4 用一个速查表收尾防坑最后把我实际项目里最常用的几个写法汇总成一张速查表打印出来贴在工位上也不为过目标正确写法说明快速打印变量fmt.Println(x)自动加空格和换行打印结构体带字段名fmt.Printf(%v\n, u)调试结构体第一选择打印完整语法形式fmt.Printf(%#v\n, u)贴到 issue 里最好用打印变量类型fmt.Printf(%T\n, x)反射也能干但这样最快整数补零fmt.Printf(%05d\n, 42)输出00042保留两位小数fmt.Printf(%.2f\n, 3.14159)注意浮点精度坑带引号字符串fmt.Printf(%q\n, s)排查不可见字符利器字符串拼接fmt.Sprintf(hello %s, name)少写一堆包装错误fmt.Errorf(原因: %w, err)保留错误链写文件fmt.Fprintf(f, id%d\n, id)f是os.File时间格式化t.Format(2006-01-02 15:04:05)参考时间2006字符串转时间time.ParseInLocation(...)别忘时区我自己刚开始用 Go 时也无数次怀疑过2006-01-02 15:04:05是不是写错了后来明白了参考时间的设计逻辑之后反而觉得这种“所见即所得”的模板方式很顺手。格式化输出的核心不是背动词而是搞清楚你手里的值是什么类型、要给人看还是给机器读、是在标准输出还是文件里。把这几个问题想清楚fmt包那点 API 说白了就是一张查表的事。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。