I Want My Own Fancy F-String Format Specifiers… Sure You Can
You're familiar (perhaps) with the format specifiers you can use in f-strings, such as `{:0.2f}`. But can you create your own?
You'd be surprised to know how much of what I plan and write doesn't make the cut. I'm working on an article about properties–you'll read it soon–and I went down a rabbit hole–I often do–as I was exploring the code to present in that article. It didn't take long to realise that I'll have to axe it from the article…
…but how about giving it its own article where it can take centre stage?
So here we are. Do you want to create your own f-string format specifiers? Read on…
What Are F-String Format Specifiers? [A Brief Recap]
This is not an article about f-strings. And I don't want this article to be longer than it needs to. Luckily, I wrote about f-strings in the past: The Curious Little Shop at The End of My Street • Python's f-strings. You can start with that article if you're new to f-strings and format specifiers.
In brief, format specifiers are the codes you can add after the expression within the braces, { }
, in an f-string. You use these codes to control how the object is formatted. Here are a few examples:

But you can use other format specifiers on other data types, too. Dates and times are a common example:
Right. That's the end of the short recap. More in my previous article if you need: The Curious Little Shop at The End of My Street • Python's f-strings.
Create Your Own Format Specifiers
In the article I'm writing about properties, I'm using a ClubMember
class. This class keeps track of members of an athletics club. [Athletics in British English is (roughly) Track and Field in US English.]
Here's a simplified version of this class with just enough for what you need for this article. You'll explore the full class when you explore properties in the future article:
The class includes the .__init__()
method to create the data attributes .first_name
and .last_name
. The .__str__()
special method creates the string representation that displays the athlete's full name.
An up-and-coming young athlete just joined the club. Let's create an instance for him:
Since the class has a .__str__()
, print()
will use the string that .__str__()
returns:
Michael Johnson
However, the club's software often needs to display the name in different formats. Here are a few examples:
Let's welcome our new member, Michael Johnson
Hello Michael!
400m (MJ)
400m: M. Johnson
M. J.
You'd like to use f-strings to display all these options and use format specifiers to control how you display the athlete's name.
Your class, your rules. Here are the rules for the format code you'd like to use:
The code should have the format
"<num>f<sep><num>l<end>"
.The number
<num>
before the letter f indicates how many characters to include from the first name.The number
<num>
before the letter l indicates how many characters to include from the last name.You can use
*
instead of a number.*f
represents the full first name, and*l
is the full last name.The separator
<sep>
and the end characters<end>
could be any non-numeric characters to place between the first and last names and after the last name. You can't use*
as one of the separator characters.
Here are some examples. I'm showing them as f-string format specifiers directly–so, within curly braces and after a colon:
{:*f *l}
displays the full first name and last name separated by a space:Michael Johnson
{:*f0l}
displays only the first name (since the last name is set to zero characters):Michael
{:1f1l}
displays the initials with no punctuation or spaces:MJ
{:1f. 1l.}
displays the initials followed by a dot and with a space between them:M. J.
{:1f. *l}
displays an initial for the first name followed by a dot and a space, and then the full last name:M. Johnson
So, how does Python know what to do with format specifiers? Indeed, how does it know how to perform any core operation with objects?
The answer: special methods (aka dunder methods)
Note: It's probably easier to create instance methods to control these bespoke string displays. You won't need to parse format specifiers, as you'll do in this article, and you'll be able to deal directly with method arguments. But since I want to show you .__format__()
today, I'll choose the format specifier option!
A First Glimpse at the .__format__()
Special Method
Let's use an even simpler class to explore the .__format__()
special method:
This class doesn't have a .__str__()
or a .__repr__()
special method. Therefore, the default .__repr__()
is used when using print()
and in the f-string:
<__main__.BasicClass object at 0x106713b50>
Here's the object: <__main__.BasicClass object at 0x106713b50>
You can define the .__str__()
special method:
It’s also a good habit to define .__repr__()
for debugging, though it’s not essential here, so I'll leave it out.
Here's the output now:
42
Here's the object: 42
What happens if you use a format specifier in the f-string?
This raises an error:
TypeError: unsupported format string passed to BasicClass.__format__
Sure, that's a silly format specifier, you're thinking. Try using one that works for integers, say:
Same error:
TypeError: unsupported format string passed to BasicClass.__format__
The error complains about an unsupported format string that's passed to BasicClass.__format__()
. But you haven't defined .__format__()
in BasicClass
.
All classes inherit from the object
base class, and therefore, all classes inherit the default .__format__()
special method.
But you can override this and write your own:
Here's the output from the call to print()
, which has the f-string with a format specifier:
This is the format specifier: 10
The format specifier is of type <class 'str'>
Here's the object: Just testing!
The first line in the printout is from the call to print()
within .__format__()
. It shows that the format specifier is whatever follows the colon after the expression in the f-string's curly braces.
The second line is also from .__format__()
and shows that the format specifier passed to .__format__()
is a string.
These first two lines show that the code calls .__format__()
. Now, look at the last line in the code, which has a call to print()
and the f-string with a format specifier. The expression in the f-string's curly braces is replaced by whatever .__format__()
returns.
In this case, .__format__()
always returns the string "Just testing!"
, which is hard-coded in its return
statement.
Let's replace the code within .__format__()
:
The code in .__format__()
checks whether the format specifier is the string "meaning of life"
. If it is and if the object's value is 42, the special method returns the string "This is the meaning of life"
. If the object has any other value, the method returns the value as a string. The .__format__()
method must always return a string.
The expression after the return keyword is the conditional expression. If you're not familiar with the conditional expression, you can read If You Find if..else in List Comprehensions Confusing, Read This, Else…. This article is about the use of the conditional expression in list comprehensions, but it explains how the conditional expression works.
There's only one valid format specifier now–it's the string "meaning of life"
. Everything else raises an error:
ValueError: Invalid format specifier
Let's check that the "meaning of life"
format specifier works:
And the output shows the phrase we're expecting:
Here's the object: This is the meaning of life
What if you create an instance with a different value?
The output now shows the value 24
since the conditional expression in the if
block in .__format__()
returns the value after else
.
There's one important issue with this version of .__format__()
. Try using the object in an f-string without a format specifier:
This also raises the same error:
ValueError: Invalid format specifier
This seems bizarre since there isn't any format specifier in the f-string this time. Add a couple of print()
calls in .__format__()
to check whether this method is still called:
The !r
in the f-string in the second print()
call ensures the repr()
string representation is used. Here's the output you see before the method raises the error:
The __format__ special method has been called
The format specifier is ''
Therefore, the program still calls .__format__()
even though you didn't use any format specifier. The format specifier is the empty string.
Let's update .__format__()
:
If the format specifier is the empty string, which is falsy, then .__format__()
returns the str()
representation:
Here's the object: 24
Now, back to ClubMember
.
Do you want to join a forum to discuss Python further with other Pythonistas? Upgrade to a paid subscription here on The Python Coding Stack to get exclusive access to The Python Coding Place's members' forum. More Python. More discussions. More fun.
And you'll also be supporting this publication. I put plenty of time and effort into crafting each article. Your support will help me keep this content coming regularly and, importantly, will help keep it free for everyone.
Adding .__format__()
to ClubMember
Let's remind ourselves of the rules we want for our format codes:
The code should have the format
"<num>f<sep><num>l<end>"
.The number
<num>
before the letter f indicates how many characters to include from the first name.The number
<num>
before the letter l indicates how many characters to include from the last name.You can use
*
instead of a number.*f
represents the full first name, and*l
is the full last name.The separator
<sep>
and the end characters<end>
could be any non-numeric characters to place between the first and last names and after the last name. You can't use*
as one of the separator characters.
And here's the class so far:
You can now add .__format__()
and start working on meeting all the requirements for the format codes:
Recall that format_spec
is a string. The if
statement ensures that the object's .__str__()
is used if you don't add any format specifier.
Parsing this format code would be an ideal use case for regular expressions (regex). But fear not! I'll take a different route in this article, otherwise, I'll get sidetracked by regular expressions (which, in any case, I don't know very well!)
A valid format specifier must have an "f"
and an "l"
. Let's start by finding where they are in the format_spec
string:
Let's use an example format specifier: {:5f-8l}
. The format_spec
string is "5f-8l"
. Therefore, the letter "f"
is at index 1
and "l"
is at index 4
.
Note: to keep this code from getting too long and complex, I'll only include basic validation in .__format__()
to check for invalid code formats. There are other points of failure you'd need to check for, but we'll assume you'll always use valid codes in this example. Also, I mentioned earlier that regex is another solution for this problem, but I chose a friendlier route in this article.
So, let's add some basic validation:
This validation takes care of format specifiers that don't include "f"
or "l"
, but it doesn't cater for other invalid formats.
Finding the number of characters needed for the first name
You can now get the number that represents the length you wish to use for the first name:
In the example you used earlier, the format specifier "5f-8l"
, first_length
is the string "5"
. You can't convert this to an integer just yet, as the character before "f"
could be the wildcard *
, which represents the full first name. However, you can replace the *
with the length of the first name:
If first_length
is "*"
, you replace it with the length of the first name. Otherwise, you cast the digits in the string first_length
into an integer.
You can use a conditional expression if you prefer:
first_length = len(self.first_name) if first_length == "*" else int(first_length)
However, I'll keep the longer format in my version for clarity–there's plenty already happening elsewhere in the code!
Extracting the separator characters
You can now move on beyond the "f"
in the format specifier. Any characters after the "f"
that aren't digits or *
must be part of the separator. You can look at the example format specifier you used earlier, "5f-8l"
. The first character after "f"
is the dash or hyphen, "-"
. It's not a digit, and it's not *
. Therefore, it must be the separator. The separator can have more than one character:
The sep
starts as an empty string. You then iterate through the format_spec
string starting from index f_index + 1
. This is the character immediately after the "f"
. You add each character that isn't a digit or *
to sep
. Once you find a digit or *
, you break the loop.
You may be wondering why you need enumerate()
at all in the loop since you don't use index
. However, the value of index
in the for
loop iteration you break is the index of the first character after the separator. This is where you need to start when you parse the last name's display length in the next step.
Note that you use the start=f_index + 1
keyword argument in enumerate()
. Therefore, enumerate()
starts counting from this value. In our example, "5f-8l"
, f_index + 1
is 2
. The for
loop starts from this index, and enumerate()
also starts counting from 2
.
The loop's first iteration considers the character "-"
. Since this isn't a digit or *
, it's added to sep
. So sep
is now "-"
. The loop moves to the next iteration, and index
is 3
. The character is now "8"
, which is a digit. Therefore, the loop breaks and index
remains "3"
. You'll need this in the next step.
Note that if there's no separator, then the loop will break on the first iteration, and sep
will remain the empty string.
Finding the number of characters needed for the last name and the end characters
Next, you look at the characters in the format specifier starting from index index
, the spot you stopped at in the previous step, up to but excluding the letter "l"
. These characters represent the display length of the last name. They may represent either a number or *
. Anything left after the "l"
represents the end characters:
You now have two integers, first_length
and last_length
, with the required display lengths for the first and last names. And you have two strings, sep
and end
. Both sep
and end
could be empty strings.
Putting everything together
The .__format__()
special method needs to return a string. You can now combine the first and last names, using the required number of characters, with the separator and end strings. This is the completed class for this article:
The f-string in the return
statement includes four curly brace pairs:
The first one includes the first
first_length
characters of the first name.first_length
may be the entire length of the first name if the format specifier includes*
before the"f"
.The second set of curly braces contains
sep
.The third set mirrors the first one but for the last name.
The final set of curly braces contains
end
.
Note that there are no spaces between the sets of curly braces. Any spaces can be included in sep
or end
in the format specifier.
Trying Out the Fancy (and Custom) Format Specifiers
Let's try this out:
The first f-string doesn't have any format specifier. The code should fall back to
.__str__()
. Here's the output from the firstprint()
call:
Let's welcome our new member, Michael Johnson
The curly braces in the second example contain a format specifier after the object's name:
{mj:*f0l}
. The first part of the format specifier is*f
, which indicates the full first name. The second part,0l
, indicates that there should be zero characters from the last name. There are no additional characters in the format code between the first and last name segments or at the end. Here's the output:
Hello Michael
The format specifier in the third example is
{mj:1f1l}
. There's one letter from the first name followed immediately by one letter from the last name. These are just the initials with no punctuation:
400m-MJ
Next up is
{mj:1f. *l}
. Now, you have one letter from the first name followed by a dot and a space. Then there's the full last name:
400m: M. Johnson
The final example has the format specifier
{mj:1f. 1l.}
. These are the initials again–one letter from each–but each initial is followed by a dot, and there's a space between them:
M. J.
And before I conclude, the .__format__()
special method also allows you to use your object as an argument to the built-in format()
function:
This again gives you the initial of the first name followed by a dot and a space and the full last name:
M. Johnson
You can also check what happens with an invalid format specifier while remembering that this code only includes basic validation and doesn't fully validate all failure points:
There's no "l"
in this format code. Therefore, you get an error:
ValueError: Invalid format specifier
But here's one more example that may seem incorrect but still works:
The first and last names are both shorter than 20 characters, but the format specifier requires 20 characters for each. The output gives the full first and last names in this case:
Michael Johnson
To see why this works, let's look at one of the lines just before the return
statement:
first_part = self.first_name[:first_length]
Since self.first_name
is the string "Michael"
and first_length
is the integer 20
in this example, the expression after the equals sign is equivalent to "Michael"[:20]
. Slicing is more robust to indices that go beyond the size of the sequence than indexing.
"Michael"[20]
–this is indexing since there's no colon–returns an IndexError
. But "Michael"[:20]
–this one is slicing–does not. Read more about slicing in A Slicing Story.
Time to wrap up.
Final Words
There's nothing magic about format specifiers in f-strings. They're arguments passed to an object's .__format__()
dunder method, as you've seen in this article. What you do with them is up to you. You can put in any logic you wish in .__format__()
.
The honest truth is that you're unlikely to need to define .__format__()
too often in your code. So, file this one under the nice to know category. But you never know when you'll have that application that requires custom format specifiers. Come back to this article when you do!
Code in this article uses Python 3.13
The code images used in this article are created using Snappify. [Affiliate link]
You can also support this publication by making a one-off contribution of any amount you wish.
For more Python resources, you can also visit Real Python—you may even stumble on one of my own articles or courses there!
Also, are you interested in technical writing? You’d like to make your own writing more narrative, more engaging, more memorable? Have a look at Breaking the Rules.
And you can find out more about me at stephengruppetta.com
Further reading related to this article’s topic:
The Curious Little Shop at The End of My Street • Python's f-strings
If You Find if..else in List Comprehensions Confusing, Read This, Else…
Python's F-String for String Interpolation and Formatting – Real Python
Also:
Appendix: Code Blocks
Code Block #1
number = 42
f"Here's a number: {number:10}"
# "Here's a number: 42"
another_number = 3.141592653589793
f"Here's another number: {another_number:.2f}"
# "Here's another number: 3.14"
Code Block #2
import datetime
today = datetime.datetime.now()
f"Today is {today:%A}. Here's the full date: {today:%d %B, %Y}"
# "Today is Wednesday. Here's the full date: 19 March, 2025"
Code Block #3
class ClubMember:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
def __str__(self):
return f"{self.first_name} {self.last_name}"
Code Block #4
class ClubMember:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
def __str__(self):
return f"{self.first_name} {self.last_name}"
mj = ClubMember("Michael", "Johnson")
print(mj)
Code Block #5
class BasicClass:
def __init__(self, value):
self.value = value
an_object = BasicClass(42)
print(an_object)
print(f"Here's the object: {an_object}")
Code Block #6
class BasicClass:
def __init__(self, value):
self.value = value
def __str__(self):
return str(self.value)
an_object = BasicClass(42)
print(an_object)
print(f"Here's the object: {an_object}")
Code Block #7
# ...
print(f"Here's the object: {an_object:meaning of life}")
Code Block #8
# ...
print(f"Here's the object: {an_object:10}")
Code Block #9
class BasicClass:
def __init__(self, value):
self.value = value
def __str__(self):
return str(self.value)
def __format__(self, format_spec):
print(f"This is the format specifier: {format_spec}")
print(f"The format specifier is of type {type(format_spec)}")
return "Just testing!"
an_object = BasicClass(42)
print(f"Here's the object: {an_object:10}")
Code Block #10
class BasicClass:
def __init__(self, value):
self.value = value
def __str__(self):
return str(self.value)
def __format__(self, format_spec):
if format_spec == "meaning of life":
return (
"This is the meaning of life"
if self.value == 42
else str(self.value)
)
else:
raise ValueError("Invalid format specifier")
an_object = BasicClass(42)
print(f"Here's the object: {an_object:10}")
Code Block #11
# ...
print(f"Here's the object: {an_object:meaning of life}")
Code Block #12
# ...
an_object = BasicClass(24)
print(f"Here's the object: {an_object:meaning of life}")
Code Block #13
# ...
print(f"Here's the object: {an_object}")
Code Block #14
class BasicClass:
# ...
def __format__(self, format_spec):
print("The __format__ special method has been called")
print(f"The format specifier is {format_spec!r}")
if format_spec == "meaning of life":
return (
"This is the meaning of life"
if self.value == 42
else str(self.value)
)
else:
raise ValueError("Invalid format specifier")
# ...
Code Block #15
class BasicClass:
def __init__(self, value):
self.value = value
def __str__(self):
return str(self.value)
def __format__(self, format_spec):
if not format_spec:
return str(self)
if format_spec == "meaning of life":
return (
"This is the meaning of life"
if self.value == 42
else str(self.value)
)
else:
raise ValueError("Invalid format specifier")
an_object = BasicClass(24)
print(f"Here's the object: {an_object}")
Code Block #16
class ClubMember:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
def __str__(self):
return f"{self.first_name} {self.last_name}"
Code Block #17
class ClubMember:
# ...
def __format__(self, format_spec):
if not format_spec:
return str(self)
Code Block #18
class ClubMember:
# ...
def __format__(self, format_spec):
if not format_spec:
return str(self)
f_index = format_spec.find("f")
l_index = format_spec.find("l")
Code Block #19
class ClubMember:
# ...
def __format__(self, format_spec):
if not format_spec:
return str(self)
if "f" not in format_spec or "l" not in format_spec:
raise ValueError("Invalid format specifier")
f_index = format_spec.find("f")
l_index = format_spec.find("l")
Code Block #20
class ClubMember:
# ...
def __format__(self, format_spec):
if not format_spec:
return str(self)
if "f" not in format_spec or "l" not in format_spec:
raise ValueError("Invalid format specifier")
f_index = format_spec.find("f")
l_index = format_spec.find("l")
first_length = format_spec[:f_index]
Code Block #21
class ClubMember:
# ...
def __format__(self, format_spec):
if not format_spec:
return str(self)
if "f" not in format_spec or "l" not in format_spec:
raise ValueError("Invalid format specifier")
f_index = format_spec.find("f")
l_index = format_spec.find("l")
# Parse required display length for first name
first_length = format_spec[:f_index]
if first_length == "*":
first_length = len(self.first_name)
else:
first_length = int(first_length)
Code Block #22
class ClubMember:
# ...
def __format__(self, format_spec):
if not format_spec:
return str(self)
if "f" not in format_spec or "l" not in format_spec:
raise ValueError("Invalid format specifier")
f_index = format_spec.find("f")
l_index = format_spec.find("l")
# Parse required display length for first name
first_length = format_spec[:f_index]
if first_length == "*":
first_length = len(self.first_name)
else:
first_length = int(first_length)
# Parse the separator
sep = ""
for index, char in enumerate(
format_spec[f_index + 1:],
start=f_index + 1
):
if not char.isdigit() and char != "*":
sep += char
else:
break
Code Block #23
class ClubMember:
# ...
def __format__(self, format_spec):
#...
# Parse required display length for first name
# ...
# Parse the separator
# ...
# Parse required display length for last name
last_length = format_spec[index:l_index]
if last_length == "*":
last_length = len(self.last_name)
else:
last_length = int(last_length)
# Parse the end characters
end = format_spec[l_index + 1:]
Code Block #24
class ClubMember:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
def __str__(self):
return f"{self.first_name} {self.last_name}"
def __format__(self, format_spec):
if not format_spec:
return str(self)
if "f" not in format_spec or "l" not in format_spec:
raise ValueError("Invalid format specifier")
f_index = format_spec.find("f")
l_index = format_spec.find("l")
# Parse required display length for first name
first_length = format_spec[:f_index]
if first_length == "*":
first_length = len(self.first_name)
else:
first_length = int(first_length)
# Parse the separator
sep = ""
for index, char in enumerate(
format_spec[f_index + 1:],
start=f_index + 1
):
if not char.isdigit() and char != "*":
sep += char
else:
break
# Parse required display length for last name
last_length = format_spec[index:l_index]
if last_length == "*":
last_length = len(self.last_name)
else:
last_length = int(last_length)
# Parse the end characters
end = format_spec[l_index + 1:]
first_part = self.first_name[:first_length]
last_part = self.last_name[:last_length]
return f"{first_part}{sep}{last_part}{end}"
Code Block #25
# ...
mj = ClubMember("Michael", "Johnson")
print(f"Let's welcome our new member, {mj}")
print(f"Hello {mj:*f0l}")
print(f"400m-{mj:1f1l}")
print(f"400m: {mj:1f. *l}")
print(f"{mj:1f. 1l.}")
Code Block #26
# ...
print(format(mj, "1f. *l"))
Code Block #27
# ...
print(f"{mj:1f3}")
Code Block #28
# ...
print(f"{mj:20f 20l}")
For more Python resources, you can also visit Real Python—you may even stumble on one of my own articles or courses there!
Also, are you interested in technical writing? You’d like to make your own writing more narrative, more engaging, more memorable? Have a look at Breaking the Rules.
And you can find out more about me at stephengruppetta.com
Good post. They say that, if you have a problem, and you solve with a regular expression, now you have two problems. That said, for all their challenge, they are powerful, and this is one situation where I might choose to use a (very well documented) regex. FWIW, I just posted a Note with some code using a regex to solve this.
The regex: ^ (\d+|\*) f (.*) (\d+|\*) l (.*) $
Spaces added for clarity. It says: starting at the beginning of the string there are either some digits OR the literal '*' followed by a literal 'f' followed by some characters followed by some digits OR the literal '*' followed by a literal 'l' followed by some characters to the end of the string. Clear as mud, right? :)
The Note with a working example:
https://substack.com/profile/195807185-wyrd-smythe/note/c-102034331