CherryPy I18n Cookbook with Babel and Jinja2

Learn how to make the most of CherryPy, and prepare a multilingual web application by leveraging the power of Babel and Jinja2—the perfect recipe for internationalization and localization in Python.

CherryPy is one of the most prominent, minimalist Python frameworks for building web applications. It has been around for more than a decade, and its pythonic, object-oriented architecture makes it easy to integrate with other packages.

Having said that, CherryPy does not come with built-in internationalization. In this tutorial, you will learn to implement your own server-side i18n in CherryPy using the following Python packages:

  • Jinja2 – HTML templating support with custom filters.
  • Babel – integrated utilities to format number and datetime; provides support for pluralization functionality based on CLDR pluralization rules.

🗒 Note » We also have quite a few server-side i18n tutorials that might interest you. Feel free to give them a read:

Let us get it started with the setup and installation. At the end of the tutorial, you should have a restaurant webpage showcasing your menu.


Installing the Python Package

You can find the complete code in the following GitHub repository. Feel free to clone it into your local folder as a reference. It is highly recommended to create a virtual environment before you continue. Activate it, and run the following command to install CherryPy via pip install.

pip install cherrypy

Next, let’s install Babel, an integrated collection of utilities for localization and internationalization. Run the following command:

pip install babel

Once you are done with it, run the following command to install Jinja2. This Python package provides templating support to render and format webpages.

pip install jinja2

Creating Language Files

To keep it simple and short, our language files consist of simple JSON format. You can easily extend and incorporate a message catalog using Babel in the future. Create a new folder called languages in your working directory. Inside the folder, create a new file called en_US.json. You should use the gettext naming convention that follows the <language>_<territory> format.

Add the following content inside your language file. These are just key-value pairs that contain the titles and descriptions for our menu. There is an addition of the plural form for the customer key, denoted as customer_plural as part of the pluralization feature later on.

  "food_title_01": "Sandwich Breakfast: A Real NYC Classic",
  "food_description_01": "Slice ham, cheese, lettuce, tomatoes, pickles",
  // ...
  "food_title_08": "Le French",
  "food_description_08": "French toast, croissant with swiss cheese",
  "menu": "Our Menu",
  "customer": "customer",
  "customer_plural": "customers",
  "serve": "served",
  "last_updated_text": "Last updated"

Next, create another json file for the German translation, called de_DE.json. Append the following inside it:

  "food_title_01": "Sandwich-Frühstück: Ein echter NYC-Klassiker",
  "food_description_01": "Schinken, Käse, Salat, Tomaten, Gurken in Scheiben schneiden",
  // ...
  "food_title_08": "Le French",
  "food_description_08": "French Toast, Croissant mit Schweizer Käse",
  "menu": "Unser Menü",
  "customer": "Kunde",
  "customer_plural": "Kunden",
  "serve": "bedient",
  "last_updated_text": "Zuletzt aktualisiert"

HTML Template

As usual, let’s use a free HTML template to make our life easier. This tutorial is based on the Food Blog Template provided by W3.CSS. The image below illustrates the original look and feel.

Since the main gist of this tutorial is more on i18n, let’s modify it into a lite version instead. Create a new folder called templates in your root directory. Inside it, create a new file called index.html.

Open it up and fill it with the following HTML code.

<!DOCTYPE html>
<title>Food Blog</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="">
<link rel="stylesheet" href="">
body,h1,h2,h3,h4,h5,h6 {font-family: "Karma", sans-serif}
.w3-bar-block .w3-bar-item {padding:20px}



Next, add the following content piece by piece inside the body tag. Let’s start by adding a menu header that always remains at the top of the page.

<!-- Top menu -->
<div class="w3-top">
  <div class="w3-white w3-xlarge" style="max-width:1200px;margin:auto">
    <p class="w3-center w3-padding-16">{{ menu }}</p>

Continue by appending the following code block right below the menu div tag of the index.html file.

<!-- !PAGE CONTENT! -->
<div class="w3-main w3-content w3-padding" style="max-width:1200px;margin-top:100px">
</div> <!-- End Page Content-->

The next step is to define the HTML code for two photo grids that showcase the photos and descriptions of the food.

<!-- First Photo Grid-->
<div class="w3-row-padding w3-padding-16 w3-center" id="food">
  <div class="w3-quarter">
    <img src="/static/images/sandwich.jpg" alt="Sandwich" style="width:100%">
    <h3>{{ food_title_01 }}</h3>
    <p>{{ food_description_01 }}</p>

  <!-- ... -->
  <div class="w3-quarter">
    <img src="/static/images/wine.jpg" alt="Pasta and Wine" style="width:100%">
    <h3>{{ food_title_04 }}</h3>
    <p>{{ food_description_04 }}</p>

<!-- Second Photo Grid-->
<div class="w3-row-padding w3-padding-16 w3-center">
  <div class="w3-quarter">
    <img src="/static/images/popsicle.jpg" alt="Popsicle" style="width:100%">
    <h3>{{ food_title_05 }}</h3>
    <p>{{ food_description_05 }}</p>

  <!-- ... -->

  <div class="w3-quarter">
    <img src="/static/images/croissant.jpg" alt="Croissant" style="width:100%">
    <h3>{{ food_title_08 }}</h3>
    <p>{{ food_description_08 }}</p>

Next, add the following code below the last photo grid. This code represents the footer of our webpage, which shows:

  • total customers served
  • last update date
  <span>{{ customer_value | number_formatting(locale) }} {{ customer | plural_formatting(customer_value, locale) }} {{ serve }}</span>
  <span class="w3-right">{{ last_updated_text }}: {{ last_updated_value | date_formatting(locale) }}</span>

Note that some of the tags contain variables marked with double curly brackets.

<p>{{ variable_name }}</p>

Jinja2 will replace the content based on the key-value pairs that you have passed when rendering the template. If you have assigned variable_name to the Hello, world! string, Jinja2 will render it as follows:

<p>Hello, world!</p>

In addition, you can specify your own custom filters to process the text as well. For example:

<p>{{ variable_name | uppercase_filter }}</p>

In this case, Jinja2 will process the value of variable_name by passing it to the uppercase_filter function. Let’s say our filter returns the input value as uppercase, the end result will be

<p>HELLO, WORLD!</p>

In fact, you can pass additional parameters to the filter as well. For example:

<p>{{ variable_name | uppercase_filter('string', 42) }}</p>

Jinja2 will call the respective function as follows, where the first parameter is always tied to the value of variable_name.

uppercase_filter('Hello, world!', 'string', 42)

Moving on to the next section on implementation, which covers custom filters.


Now, create a new Python file in the root directory of your project called


Add the following import declarations at the top of the file.

import cherrypy
import os
import glob
import json
import datetime
from jinja2 import Environment, FileSystemLoader
from babel.dates import format_date, format_datetime, get_timezone
from babel.numbers import format_number
from babel.plural import PluralRule


Initialize the following variables inside the file.

env = Environment(loader=FileSystemLoader('templates'))

default_fallback = 'en_US'
languages = {}
timezone = get_timezone('Asia/Singapore')
plural_rule = PluralRule({'one': 'n in 0..1'})

FileSystemLoader is responsible for loading our HTML template from a folder. In this case, it is set to load directly from the templates folder.

The default_fallback variable holds the default fallback locale while the languages variable will store the languages data later on.

Another variable called timezone is responsible for holding the timezone of our server. In this case, it is set as Asia/Singapore.

Babel provides great support for pluralization based on CLDR plural rules. CLDR refers to Common Locale Data Repository and comes with the largest and most extensive locale-specific information. The data can be used for adapting software to internationalization and localization tasks. For Babel, you have to define and supply your own pluralization rules.

Let’s create a simple rule to plural_rule variable holding the following tags:

  • one – when the value is 0 or 1
  • other – the default value if the value is not tagged under one

You can specify other rules like the following example:

{'one': 'n is 1', 'few': 'n in 2..4'}

The rule will tag one if the input value is 1. It will tag few if the input value ranges from 2 to 4. An other input will be tagged as other. Currently, Babel comes with the following tags:

  • zero
  • one
  • two
  • few
  • many
  • other

Babel does not support user-defined keys for plurals, and each rule should be mutually exclusive. This means that the count (n) condition should only be true for one of the plural rule elements. Moreover, you are not allowed to re-define the tags. Once you have defined the rules for a tag, it returns a callable object that t accepts a positive or negative number. The number can be integer or float.

Loading Language Data

Continue by appending the following code in file.

lang_file_paths = glob.glob("languages/*.json")
for lang_file_path in lang_file_paths:
    filename = lang_file_path.split('\\')
    lang_code = filename[1].split('.')[0]

    with open(lang_file_path, 'r', encoding='utf8') as file:
        languages[lang_code] = json.load(file)

Let’s leverage the power of the glob module to load all of your language files dynamically. By doing so, adding support for a new language is just a matter of creating a new language file inside the languages folder.

Plural Formatting

We have already created a few custom filters in our HTML template file. Let us implement the corresponding functions here. We will start with the plural_formatting function. This is a Jinja2 filter that will resolve the key in the language file during template rendering. Add the following code inside the file.

def plural_formatting(key_value, input, locale):
    key = ''
    for i in languages[locale]:
        if(key_value == languages[locale][i]):
            key = i

    if not key:
        return key_value

    plural_key = f"{key}_plural"

    if(plural_rule(input) != 'one' and plural_key in languages[locale]):
        key = plural_key

    return languages[locale][key]

First, let’s identify the corresponding key based on the input. Then, check if the input is under the other tag, based on the rules that have been defined earlier. Continue by checking if there exists a plural form key in our language data. Return the new value if a key is found.

Number Formatting

Babel provides a way to format numbers via the format_number function. All you need to do is pass in the input and the desired locale as follows:

format_number(1.2345, locale='en_US')
# '1.234'

format_number(12345, locale='de_DE')
# '12.345'

Our number_formatting function should look like this; append it to file.

def number_formatting(input, locale):
    return format_number(input, locale=locale)

Date Formatting

Likewise, you can easily format datetime based on the current locale using Babel. For example,

format_date(date(2020, 4, 12), locale='en_US')
# 'Apr 12, 2020'

The function accepts an optional format argument with the following options to choose from:

  • short
  • medium (the default value)
  • long
  • full

Let’s implement our custom date_formatting function inside the file.

def date_formatting(input, locale):
    # format_datetime(, tzinfo=timezone, format='full', locale=locale)
    return format_date(input, format='full', locale=locale)

It will return the formatted datetime, based on the current locale. You can find another example as a comment inside the function for formatting, based on timezone via the tzinfo argument.

Defining Custom Filters for Jinja2

Add the final touch by registering all of the functions as filters to Jinja2’s Environment inside the file.

env.filters['plural_formatting'] = plural_formatting
env.filters['number_formatting'] = number_formatting
env.filters['date_formatting'] = date_formatting

Creating CherryPy Routes

Create a new class called FoodBlog inside the file. Let us add a simple route inside the class. It will return Hello world! back to the users. You need to decorate each function with a cherrypy.expose decorator to make it a route.

class FoodBlog(object):
    def index(self):
        return "Hello world!"

You can use the cherrypy.popargs() decorator on your class for Restful-style dispatching. This provides capabilities to set a locale via the route parameter. Decorate the FoodBlog class with the popargs decorator as follows:

class FoodBlog(object):

Continue by adding a new route called about_us to the FoodBlog class.

class FoodBlog(object):
    def index(self):
        return "Hello world!"

    def about_us(self, locale=default_fallback):
        if(locale not in languages):
            locale = default_fallback

        template = env.get_template('index.html')
        return template.render(**languages[locale], locale=locale,, customer_value=25)

Supposing the server ran on localhost using port 8080, our new route would be accessible via the following URLs:

  • http://localhost:8080/en_US/about_us
  • http://localhost:8080/de_DE/about_us

The rest of the code inside the about_us function is responsible for

  • assigning fallback locale if user defined an unsupported locale, and
  • rendering the index.html template with Jinja2.

One of the most useful but lesser-known features is that you can set aliases via the expose decorator. This allows you to provide localized routing. All you need to do is specify aliases in a list when setting up the expose decorator. For example:

@cherrypy.expose(['alias1', 'alias2'])

The example below illustrates how to configure different aliases for the about_us page.

@cherrypy.expose(['über_uns', '关于我们'])  # set aliases
def about_us(self, locale=default_fallback):

Access the new routes via any combination of the following pattern.


Replace <locale> with any of the following locale:

  • en_US
  • de_DE

Adding a Final Touch to the Main Function

Finally, let us wrap it up by adding the following code inside the’s main function. You need to define the static directory path if you intend to serve static files like images. The path needs to be an absolute instead of a relative path so that it can work.

if __name__ == '__main__':
    conf = {
        '/static': {
            'tools.staticdir.on': True,
            'tools.staticdir.dir': os.path.abspath(os.path.join(os.path.dirname(__file__), 'static'))
    cherrypy.quickstart(FoodBlog(), '/', conf)

Call the quickstart function and pass in the FoodBlog class and the config variable. Then, save your file, and head back to the command prompt.

Testing Your Server

Run the following command to start the CherryPy server.


Head over to the following URL:


You should see the following output when you scroll till the end of the webpage. Since the locale is not specified, it will default to en_US as expected.

Now, let us try the German locale.


You should see the following user interface:

You should notice that numbers and datetime are formatted correctly, based on the current locale. The next step is to play around with other values, and test pluralization and formatting.


Congratulations on completing this CherryPy i18n cookbook. You can use most of the things that you learned here on other Python web applications as well.

Now, if you feel it is the right time to let translators localize your app, give Phrase a try. The fastest, leanest, and most reliable localization management platform on the market will help you to streamline the i18n process with everything you need to:

  • Build production-ready integrations with your development workflow,
  • Invite as many users as you wish to collaborate on your projects,
  • Edit and convert localization files with more context for higher translation quality.

Sign up for a free 14-day trial, and see for yourself how it can make your life as a developer easier.

Rate this post

Automate Your Localization Workflow for Continuous Deployment

Automate Localization for Continuous Deployment

  • Integrate Phrase into your agile environment easily
  • Import and export your localization files in any format
  • Automate your localization workflow to speed up every release