Gemini Structured Output With Zod in Next.js
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
To format a string in Python, put an f before the quotes and write any variable or expression inside curly braces, like f"Hello {name}". Add a colon and a format spec to control how the value looks, for example f"{price:.2f}" for two decimal places. This guide collects the f-string formatting examples I reach for most: numbers, alignment, dates, debugging and the newer features in Python 3.12 and 3.14.
Before f-strings, I was gluing strings together with + and str(), which got messy quickly. F-strings fixed that. Every example below was run with Python 3.13 (or 3.14 where noted), and the comments at the bottom of each block show the real output.
An f-string (formatted string literal) is a normal string with an f or F prefix. Anything inside {} is evaluated as a Python expression when the line runs, and the result is turned into text.
name = "Mahir"
lessons_done = 7
total = 20
print(f"Hi {name}, you have finished {lessons_done} of {total} lessons.")
print(f"That leaves {total - lessons_done} to go.")
print(f"Your name in capitals is {name.upper()}.")
# Output:
# Hi Mahir, you have finished 7 of 20 lessons.
# That leaves 13 to go.
# Your name in capitals is MAHIR.
You're not limited to variable names. Arithmetic, method calls and function calls all work inside the braces. Keep them short, though. If the expression needs a comment to explain it, calculate it on the line above and put the variable in the f-string.
The part after the colon is called the format specification. It uses the same "mini-language" as str.format(). These are the number formats I use all the time:
price = 1249.5
ratio = 0.4567
count = 42
views = 1234567
print(f"{price:.2f}") # 2 decimal places
print(f"{price:,.2f}") # thousands separator + 2 decimals
print(f"{ratio:.1%}") # percentage with 1 decimal
print(f"{count:05d}") # pad with zeros to width 5
print(f"{views:,}") # comma groups
print(f"{views:_}") # underscore groups
print(f"{count:b} {count:x} {count:#x}") # binary, hex, hex with prefix
# Output:
# 1249.50
# 1,249.50
# 45.7%
# 00042
# 1,234,567
# 1_234_567
# 101010 2a 0x2a
.2f means "fixed-point with 2 digits after the decimal point". It rounds, it doesn't just cut off., or _ adds a separator every three digits. It goes before the precision: ,.2f, not .2f,.% multiplies by 100 and adds a percent sign, so store ratios like 0.4567, not 45.67.05d pads an integer with zeros to a width of 5, which is handy for IDs and file names like img_00042.png.b, o and x show binary, octal and hex. Add # to include the 0b, 0o or 0x prefix.When I print results in the terminal, alignment makes them much easier to read. Use < for left, > for right and ^ for centre, followed by a width. You can put a fill character before the alignment symbol.
rows = [("Python", 92), ("HTML", 85), ("JavaScript", 7)]
print(f"{'Subject':<12}|{'Score':>6}")
print("-" * 19)
for subject, score in rows:
print(f"{subject:<12}|{score:>6}")
print(f"[{'hi':^10}]") # centre in 10 characters
print(f"[{'hi':*^10}]") # centre with * as the fill
# Output:
# Subject | Score
# -------------------
# Python | 92
# HTML | 85
# JavaScript | 7
# [ hi ]
# [****hi****]
Strings are left-aligned by default and numbers are right-aligned by default, but I write the alignment out anyway so the intent is obvious. Notice the single quotes inside the double-quoted f-string, like {'Subject':<12}. That works in every Python version that has f-strings.
This is my favourite f-string feature. Put = after an expression and Python prints the expression text, an equals sign and the value. It's much faster than typing labels by hand when you're tracking down a bug.
width = 1200
height = 630
title = "Flexbox vs Grid"
print(f"{width=}, {height=}")
print(f"{width / height = :.2f}")
print(f"{title!r}")
print(f"{title=}")
# Output:
# width=1200, height=630
# width / height = 1.90
# 'Flexbox vs Grid'
# title='Flexbox vs Grid'
A few details worth knowing:
= are kept in the output, so {width / height = } prints with spaces.{width / height = :.2f}.=, Python uses repr() by default (unless you add a format spec), which is why strings show up with quotes. !r does the same thing on its own, and !s forces str().date and datetime objects accept strftime codes directly in the format spec, so you don't need to call .strftime() yourself.
from datetime import date, datetime
exam = date(2026, 11, 15)
start = datetime(2026, 10, 2, 15, 20)
print(f"Exam day: {exam:%d %B %Y}")
print(f"Weekday: {exam:%A}")
print(f"Started at {start:%H:%M} on {start:%Y-%m-%d}")
print(f"ISO format: {exam.isoformat()}")
# Output:
# Exam day: 15 November 2026
# Weekday: Sunday
# Started at 15:20 on 2026-10-02
# ISO format: 2026-11-15
The codes I use most are %d (day), %m (month number), %B (full month name), %Y (four-digit year), %A (weekday), and %H:%M (24-hour time). Month and day names depend on the system locale, so they may not be in English everywhere.
The format spec can contain its own braces. This lets you choose the precision or width at runtime, which is useful when the user picks how many decimal places to show.
value = 3.14159265
for digits in (1, 3, 5):
print(f"{value:.{digits}f}")
width = 8
print(f"[{'end':>{width}}]")
# Output:
# 3.1
# 3.142
# 3.14159
# [ end]
Since Python 3.12, f-strings are parsed properly by the language grammar (PEP 701). You can reuse the same quote type inside the braces, use backslashes inside expressions and split long expressions over several lines.
# Works on Python 3.12 and newer (PEP 701)
user = {"name": "Nusrat", "city": "Dhaka"}
print(f"{user["name"]} lives in {user["city"]}")
lines = ["first", "second"]
print(f"{'\n'.join(lines)}")
# Output:
# Nusrat lives in Dhaka
# first
# second
On Python 3.11 and older, each of those lines is a SyntaxError. If your code has to run on an older server, stick to switching between single and double quotes.
Python 3.14 added t-strings (PEP 750). They look like f-strings with a t prefix, but instead of returning a finished str, they return a Template object that keeps the static text and the values separate:
# Python 3.14+ only: t-strings return a Template, not a str
from string.templatelib import Template
name = "<b>Rafi</b>"
greeting = t"Hello {name}"
print(type(greeting).__name__)
print(greeting.strings)
print(greeting.interpolations[0].value)
# Output:
# Template
# ('Hello ', '')
# <b>Rafi</b>
That separation means a library can escape the values before joining them, for example to make HTML or SQL safer. For everyday printing and messages, f-strings are still the tool to use.
Without the prefix, Python doesn't complain. It just prints the braces literally:
name = "Mahir"
print("Hello {name}") # forgot the f
print(f"Hello {name}")
# Output:
# Hello {name}
# Hello Mahir
To print { or } in an f-string, double it. I hit this whenever I generate CSS or JSON-like text:
selector = "body"
print(f"{selector} {{ margin: 0; }}")
# Output:
# body { margin: 0; }
Floats are stored in binary, so some decimals can't be stored exactly. 2.675 is really a tiny bit less than 2.675, so it rounds down:
total = 2.675
print(f"{total:.2f}") # floats can't store 2.675 exactly
from decimal import Decimal
print(f"{Decimal('2.675'):.2f}")
# Output:
# 2.67
# 2.68
For money or anything where exact decimals matter, use Decimal, and create it from a string, not a float.
Never put user input into an SQL query with an f-string. That's how SQL injection happens: the input can close your quote and add its own SQL. Use your database library's placeholders instead. Here's a quick test with the built-in sqlite3 module:
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (email TEXT, name TEXT)")
conn.execute("INSERT INTO users VALUES (?, ?)", ("rafi@example.com", "Rafi"))
email = "rafi@example.com' OR '1'='1" # a malicious input
# Unsafe: f"SELECT name FROM users WHERE email = '{email}'" would return every row
rows = conn.execute("SELECT name FROM users WHERE email = ?", (email,)).fetchall()
print(rows) # the placeholder treats the whole input as one plain value
# Output:
# []
With an f-string, that same input turns the WHERE clause into something that's always true, and the query returns Rafi's row even though the email doesn't match. With the ? placeholder, the database treats the whole input as plain data, so nothing matches. The same idea applies in every language. I wrote about the PHP version with PDO prepared statements in another tip.
With the logging module, the usual advice is logging.debug("User %s logged in", name) rather than an f-string. Logging only builds the message if that level is actually enabled, while an f-string is always built first.
F-strings have been in Python since 3.6. The = debugging specifier needs 3.8 or newer, reusing quotes inside braces needs 3.12, and t-strings need 3.14.
In general, yes: f-strings are usually at least as fast as str.format() and % formatting. I pick them mainly because they're easier to read, since the value sits right where it appears in the text.
Use triple quotes with the f prefix: f"""...""". Every line can contain replacement fields. You can also put several f-strings next to each other inside parentheses, and Python joins them into one string.
Yes. On Python 3.12+ you can write f"{user["name"]}". On older versions, use the other quote type: f"{user['name']}".
F-strings put the value right where it appears in the text, and the format spec after the colon controls how it looks: .2f for decimals, , for thousands, % for percentages, < ^ > for alignment, and date codes for dates. Add = when debugging, and you'll rarely need anything else.
For the full list of options, the Python docs have a page on the format specification mini-language. To build a whole list of formatted lines in one go, combine f-strings with Python list comprehensions. If you want to see what I'm building while learning, have a look at my projects.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.
A small offline habit tracker in plain HTML and JavaScript: habits saved as JSON in localStorage, streaks from local dates, a 7-day row and a JSON backup.