Skip to content
← All Python articles

Python explainer

When Not to Use an f-String

About 4 min to read. Allow extra time to work through examples.

Pass the template and the values separately to logging, SQL and translation, and use f-strings for every other string you build.

This article uses Python. You can make it your practice mode to browse related content across the site.

You change a log call to an f-string because it reads better:

logger.debug(f"cart for {user_id}: {cart}")

The output looks the same. But Python now builds the whole message before logger.debug runs, even when debug logging is off. The logger also gets only the finished text, without the template.

An f-string is the right tool when you want the finished string now. Logging, SQL and translation each need the template and the values separately. Give them that, and use f-strings everywhere else.

Pass log values as arguments

import logging

logger = logging.getLogger(__name__)


def log_payment(payment_id: str, user_id: str) -> None:
    logger.info("payment %s failed for user %s", payment_id, user_id)

The logger stores the template and the values in a log record. It joins them only when a handler writes the record out, so a disabled level skips the formatting. The record also keeps the template, so a tool that reads log records can group every "payment failed" message together.

The arguments themselves still run before the call. logger.debug("payload %s", build_payload()) calls build_payload() even with debug logging off. If that call is slow, check the level first:

if logger.isEnabledFor(logging.DEBUG):
    logger.debug("payload %s", build_payload())

Pass SQL values as parameters

import sqlite3


def user_count(connection: sqlite3.Connection, name: str) -> int:
    row = connection.execute(
        "SELECT count(*) FROM users WHERE name = ?", (name,)
    ).fetchone()
    return int(row[0])

The ? is a placeholder. The driver sends name to the database as a value, separate from the SQL. Build the same query with an f-string, and a name such as x' OR '1'='1 becomes part of the SQL and matches every row. The name for this attack is SQL injection.

Don't put quotes around ?. A placeholder can stand only for a value, such as a name or a number. When a table or column name must vary, choose it from a fixed set of allowed names in your code. Other drivers use other placeholders, such as %s in psycopg. That %s belongs to the driver, so pass the values to execute as you do with ?.

Translate the template, then fill it in

from gettext import gettext as _


def greeting(name: str) -> str:
    return _("Hello, %(name)s") % {"name": name}

Translation tools collect the literal strings in your code into a catalog. _() looks up the template in that catalog, and then % fills in the name. With _(f"Hello, {name}"), the lookup gets "Hello, Ada", which is in no catalog, so the user sees English. A named placeholder such as %(name)s also lets a translator move the name within the sentence.

Check your understanding You set logging.Formatter(style="{"). Can you now write logger.info("hello {}", name)? Show answer

No. The style setting applies to the formatter's own layout string, such as "{levelname}: {message}". The logger still joins your message and its arguments with %. With "hello {}" and one argument, logging prints a --- Logging error --- traceback instead of your message. Keep %s in log messages.

Use an f-string for everything else

Error messages, file names, printed output and __repr__ all need the finished string. Build it with an f-string. In a __repr__, !r shows string fields with their quotes, so you can see where a value starts and ends:

class User:
    def __init__(self, name: str, events: list[str]) -> None:
        self.name = name
        self.events = events

    def __repr__(self) -> str:
        return f"User(name={self.name!r}, events=<{len(self.events)} items>)"

Showing a count instead of the whole list keeps the output short.

Going further

For a subprocess, pass a list of arguments such as ["git", "log", path] instead of a shell command built with an f-string. Python then starts the program directly, without a shell, so a path such as x; rm -rf ~ stays one argument.

typing.LiteralString marks a parameter that accepts only strings built from literals in your source code. A type checker that enforces it rejects an f-string made from user input. mypy accepts the annotation but treats it as plain str, so the f-string passes. Keep passing SQL values as parameters.

Python 3.14 adds template strings, written t"...", which keep the template and the values apart. An exercise near the end of this track builds an HTML renderer and a SQL helper with them.

step 6 of 23

Pattern Matching, f-Strings and Newer Syntax

Up next Branch on a Value with match and case