Website I18n with Django REST Framework and django-parler

How to set up and serve translated content with Django REST Framework and django-parler? We’ll guide you through the entire process!

Django is a full-stack Python framework with a long history. It’s the most respected Python framework for web development, powering thousands of websites around the world.
Django offers a build-in solution for i18n using the i18n and l10 module. In fact, we have written a quick guide on Django i18n before – feel free to give it a look – but it does not mean that it is the only option out there. There is also another independent package called django-parler that offers a simpler solution for providing multilingual support. It can come in quite handy if you are using models that require multiple translations and are saved in a database. For more advanced requirements, it is better to use the framework’s built-in I18n support.
In this tutorial, we are going to expose a simple blog app API using Django REST Framework that also offers multilingual support with django-parler. All the code examples are hosted on GitHub. Let’s get started!

Installation

First, we create a new Python environment and install the necessary packages:

python3 -m venv .env
source .env/bin/activate
pip3 install django django-parler django-parler-rest djangorestframework

For storing the database models, we need a database. We use PostgreSQL in this tutorial, so we need to install some extra drivers:

pip3 install psycopg2

Then start the database service. For example, on Linux/Mac we can use the following command:

pg_ctl -D /usr/local/var/postgres start

Then create a new Django project for our blog:

django-admin startproject myblog

Next, we need to configure the database. Edit the settings.py file and change the DATABASE sections as follows:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'myBlog',
        'USER': 'myBlogUser',
        'PASSWORD': 'myPassword',
        'HOST': '127.0.0.1'
    }
}

Add the django-parler and Django REST Framework configuration:

INSTALLED_APPS += (
    'parler',
    'rest_framework'
)
PARLER_LANGUAGES = {
    None: (
        {'code': 'en',},
        {'code': 'el',},
    ),
    'default': {
        'fallbacks': ['en'],
        'hide_untranslated': False,   # Default
    }
}

Here, we’ve added two supported languages: English and Greek denoted by their language tags (en and el).
Before we run the migrations, we need to create a new user and database as per configuration because Django will complain if they are not valid. The easiest way to do that is by using the pgAdmin tool:
Django pgAdmin tool | Phrase
You will need to apply the following SQL scripts:

CREATE ROLE "myBlogUser" WITH
    LOGIN
    NOSUPERUSER
    NOCREATEDB
    NOCREATEROLE
    INHERIT
    NOREPLICATION
    CONNECTION LIMIT -1
    PASSWORD 'myPassword';
CREATE DATABASE "myBlog"
    WITH
    OWNER = "myBlogUser"
    ENCODING = 'UTF8'
    CONNECTION LIMIT = -1;

You are now ready to run the migrations …

python manage.py migrate

… and finally, create our Blog API.

Blog REST API

First, we start by creating the blog app inside the project root folder:

python manage.py startapp blog

Inside the blog/models.py file, we need to add a Post model. We are going to use the django-parler TranslatableModel to derive the translations for us:

from django.db import models
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from parler.models import TranslatableModel, TranslatedFields
class Post(TranslatableModel):
    translations = TranslatedFields(
        title = models.CharField(_("Title"), max_length=200, unique=True),
        content = models.TextField(_("Content"), blank=True)
    )
    author = models.ForeignKey(User, on_delete= models.CASCADE,related_name='blog_posts')
    updated_on = models.DateTimeField(auto_now= True)
    created_on = models.DateTimeField(auto_now_add=True)
    class Meta:
        ordering = ['-created_on']
        verbose_name = _("Post")
        verbose_name_plural = _("Posts")
    def __str__(self):
        return self.title

The TranslatedFields object wraps all the fields that require translations. To actually create objects, we can also register an admin form that automatically populates the different translation forms.
Update the following file blog/admin.py:

from django.contrib import admin
from parler.admin import TranslatableAdmin
from blog.models import Post
class PostAdmin(TranslatableAdmin):
    list_display = ('title', 'content')
    fieldsets = (
        (None, {
            'fields': ('title', 'content'),
        }),
    )
    def save_model(self, request, obj, form, change):
        obj.author_id = request.user.id
        super().save_model(request, obj, form, change)
admin.site.register(Post, PostAdmin)

Here we used the save_model method to assign the current user when we create a new Post.
Now we login to the admin panel and add a few posts with translations (both English and Greek):
Django pgAdmin tool | Phrase
The library already created different tabs for each language. For each language tag, we need to provide translations and then click the Save button.
You may be wondering what the underlying model is when we save those translations – we can find that out using the pgAdmin tool really quickly by inspecting the tables created when we apply the migrations.
Navigate to localhost > databases > myBlog > Schemas > Tables and there you will see a new table called blog_post_translation:
Szoter annotated image | Phrase
As you can see, django-parler created a one-to-many relationship between the Post model and a new model for storing the translations. This way we can programmatically fetch all translations for a given post. The master_id field maps to the post_id param as we look into the foreign key constraint:

ALTER TABLE public.blog_post_translation
    ADD CONSTRAINT blog_post_translation_master_id_9162465c_fk_blog_post_id FOREIGN KEY (master_id)
    REFERENCES public.blog_post (id) MATCH SIMPLE
    ON UPDATE NO ACTION
    ON DELETE NO ACTION
    DEFERRABLE INITIALLY DEFERRED;

In addition, it creates a unique tuple of (master_id, language_code):

ALTER TABLE public.blog_post_translation
    ADD CONSTRAINT blog_post_translation_language_code_master_id_9a50eed2_uniq UNIQUE (language_code, master_id);

Before we expose them to the API, we need to register the router query-set with REST Framework. Edit the myBlog/urls.py file with the following contents:

from django.contrib import admin
from django.urls import path, include
from parler_rest.serializers import TranslatableModelSerializer
from parler_rest.fields import TranslatedFieldsField
from rest_framework import routers, serializers, viewsets
from blog.models import Post
class PostSerializer(TranslatableModelSerializer):
    translations = TranslatedFieldsField(shared_model=Post)
    class Meta:
        model = Post
        fields = ('translations', 'author', 'created_on', 'updated_on')
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
router = routers.DefaultRouter()
router.register(r'posts', PostViewSet)
urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include((router.urls, 'blog'))),
]

Now start the server and head over to the REST API path at http://localhost:8000/api/posts/ so we can see the translated model being served there as JSON:
REST template | Phrase
There you have it, a fully-configurable REST template for serving translated models in Django.

Other Things We Can Do

Let’s see what other things we can do using this library.
Log in to the management shell first.

Accessing fields from the model

We can retrieve a Post model and check its fields:

>> from blog.models import Post
>>> first = Post.objects.first()
>>> first
<Post: My First Post>
>>> first.title
'My First Post'

To check the same field with a different translation, we need to switch to that locale first:

>>> first.set_current_language('el')
>>> first.title
'Η πρώτη μου δημοσίευση!'

Filtering translations

The translations for each post reside on a different table, and they can be filtered like any other relation:

>>> Post.objects.filter(translations__title='Η πρώτη μου δημοσίευση!')
<TranslatableQuerySet [<Post: My First Post>]>

Note that it returned the English version of the object. If we were to set either the PARLER_DEFAULT_LANGUAGE_CODE or LANGUAGE_CODE in the settings to el, then we would get the Greek translations instead:

>>> Post.objects.filter(translations__title='Η πρώτη μου δημοσίευση!')
<TranslatableQuerySet [<Post: Η πρώτη μου δημοσίευση!>]>

Manually adding translations to the model
We can update an existing model as usual:

>>> first = Post.objects.first()
>>> first.title = 'Μια άλλη ανάρτηση'
>>> first.save()
>>> Post.objects.first()
<Post: Μια άλλη ανάρτηση>

As the current selected language is GREEK this will save a translation for that locale. If we want to switch the current language and save a translation there, we will need to use the .set_current_language() method:

p = Post.objects.first()
p.title='Η πρώτη μου ανάρτηση'
p.save()
Post.objects.all()
<TranslatableQuerySet [<Post: Η πρώτη μου ανάρτηση>, <Post: Μια άλλη ανάρτηση>, <Post: Η δεύτερη μου δημοσίευση>]>
p = Post.objects.first()
p.set_current_language('en')
p.title='Demo Blog Post Title'
p.save()
>>> p
<Post: Demo Blog Post Title>
p.set_current_language('el')
p
<Post: Η πρώτη μου ανάρτηση>

We can create a new translations using the create_translation method of the Post model:

>>> p = Post.objects.create(author_id='1')
>>> p.create_translation('en', title='A different blog post')
>>> p.save()
>>> Post.objects.all()
<TranslatableQuerySet [<Post: A different blog post>, <Post: Μια άλλη ανάρτηση>, <Post: Η δεύτερη μου δημοσίευση>]>

Retrieving REST API data for one translation only
We use the translated method to retrieve query sets that have the specified language translations:

Post.objects.translated('el') # Filter only Greek translations
<TranslatableQuerySet [<Post: Μια άλλη ανάρτηση>, <Post: Η δεύτερη μου δημοσίευση>]>

In order to hook it into REST Framework, though, we need to adopt a new strategy. We can detect the client’s preferred language using the accept_language header and filter the query-set result to contain only the translations for that language. The following code is taken from this example comment here with some stability improvements:
Create a mixins.py file and add the following code:

from myblog import settings
from django.utils.translation import get_language_from_request
class TranslatedSerializerMixin(object):
    """
    Mixin for selecting only requested translation with django-parler-rest
    """
    def to_representation(self, instance):
        inst_rep = super().to_representation(instance)
        request = self.context.get('request')
        lang_code = get_language_from_request(request)
        result = {}
        for field_name, field in self.get_fields().items():
            # add normal field to resulting representation
            if field_name is not 'translations':
                field_value = inst_rep.pop(field_name)
                result.update({field_name: field_value})
            if field_name is 'translations':
                translations = inst_rep.pop(field_name)
                if lang_code not in translations:
                    # use fallback setting in PARLER_LANGUAGES
                    parler_default_settings = settings.PARLER_LANGUAGES['default']
                    if 'fallback' in parler_default_settings:
                        lang_code = parler_default_settings.get('fallback')
                    if 'fallbacks' in parler_default_settings:
                        lang_code = parler_default_settings.get('fallbacks')[0]
                for lang, translation_fields in translations.items():
                    if lang == lang_code:
                        trans_rep = translation_fields.copy()  # make copy to use pop() from
                        for trans_field_name, trans_field in translation_fields.items():
                            field_value = trans_rep.pop(trans_field_name)
                            result.update({trans_field_name: field_value})
        return result

Then add the mix into the PostSerializer:

class PostSerializer(TranslatedSerializerMixin, TranslatableModelSerializer):
    translations = TranslatedFieldsField(shared_model=Post)
    class Meta:
        model = Post
        fields = ('translations', 'author', 'created_on', 'updated_on')

Now, if we head over to http://localhost:8000/api/posts/, we will see only the translations for the English language as our default accepts-language:
Finished post list English | Phrase
If I change the default browser language (via settings) to Greek, then the Greek translations will be returned:
Finished post list Greek | Phrase
Switching the language
Using the switch_language context manager and given a support language tag, we can switch into the specified language:

>>> from parler.utils.context import switch_language
p = Post.objects.filter(translations__title='Μια άλλη ανάρτηση')
>>> with switch_language(p, 'en'):
...     print(p.title)
My First Post

Conclusion

With django-parler, we can quickly set up and use translatable fields in Django, with good automatic admin integration. We hope that this tutorial has helped you understand how it works and how to use it. For more advanced needs in terms of i18n and l10n in Django, make sure you see what Phrase can do for you in a 14-day free trial.

Keep exploring

Photo-realistic sheet music featuring developer-style translation code in place of musical notes. The staff lines show snippets like t('auth.signin.button') and JSON structures, combining the aesthetics of musical notation with programming syntax to illustrate the idea of “composable localization.”

Blog post

Localization as code: a composable approach to localization

Why is localization still a manual, disconnected process in a world where everything else is already “as code”? Learn how a composable, developer-friendly approach brings localization into your CI/CD pipeline, with automation, observability, and Git-based workflows built in.

A woman in a light sweater sits in a home office, focused on her laptop, representing a developer or content manager working on WordPress localization tasks in a calm, professional environment.

Blog post

How to build a scalable WordPress i18n workflow

WordPress powers the web, but translating it well takes more than plugins. Discover how to build a scalable localization workflow using gettext, best practices, and the Phrase plugin.

Blog post

Localizing Unity games with the official Phrase plugin

Want to localize your Unity game without the CSV chaos? Discover how the official Phrase Strings Unity plugin simplifies your game’s localization workflow—from string table setup to pulling translations directly into your project. Whether you’re building for German, Serbian, or beyond, this guide shows how to get started fast and localize like a pro.

Blog post

Internationalization beyond code: A developer’s guide to real-world language challenges

Discover how language affects your UI. From text expansion to pluralization, this guide explores key i18n pitfalls and best practices for modern web developers.

A digital artwork featuring the Astro.js logo in bold yellow and purple tones, floating above Earth's horizon with a stunning cosmic nebula in the background. The vibrant space setting symbolizes the global and scalable nature of Astro’s localization capabilities, reinforcing the article’s focus on internationalization in web development.

Blog post

Astro.js localization part 2: dynamic content localization

Learn how to localize your Astro.js website with static and dynamic content translation. Explore Astro’s built-in i18n features and Paraglide for handling UI elements, navigation, and dynamic text seamlessly.