Logo
blank Skip to main content

Best Python Framework for Backend Development: Django vs. Flask vs. Tornado

When developing a back end for a Python application, itโ€™s essential to remember that the devil is in the details. Using the right framework can boost your appโ€™s performance, security, and scalability. But to make the best choice, you often need to do a lot of research or have practical experience with a specific tool.

So, how can you know which tool to choose? At Apriorit, we have used all popular Python development tools countless times and know their pros and cons from practical experience. We share our knowledge in this article, focusing on three popular Python backend frameworks: Flask, Django, and Tornado. To demonstrate how they work, weโ€™ll develop the same back end using each framework and compare their development speed, code maintainability, and security.

This article will be useful for technical leaders looking for a fitting technology stack and expertise to quickly build their applications.

How to choose a Python development framework for your back end

An applicationโ€™s back end directly influences its security, performance, and scalability. To build a back end, developers often choose Python frameworks because they provide many pre-built libraries, components, and functionalities that help save time and benefit from the numerous advantages of Python.

Python is one of the most popular programming languages, with a huge and active community and dozens of development frameworks. Hereโ€™s how your project can benefit from determining the best Python backend framework:

  • Development speed and efficiency. If a framework provides developers with all the components and features they need out of the box, they donโ€™t need to spend extra time coding custom functionality and searching for answers to their questions.
  • Relevant security mechanisms. Different types of products require different security measures, so itโ€™s best to choose a framework with the built-in protections you need. For example, when building a web app, look for something that protects against common vulnerabilities like SQL injection and cross-site scripting. 
  • Simplified data management. A well-suited framework often comes with tools for seamless database interaction, data validation, and data management. This can streamline data handling for an application that works with large datasets.
  • Maintainability. A framework designed with long-term maintenance in mind promotes a clean code architecture, reusability, and a modular design. This ensures that future updates, bug fixes, or feature additions can be implemented easily, reducing overall technical debt.
  • Scalability options. Some frameworks are better suited for handling increased traffic and data loads. Selecting a framework that can scale horizontally or vertically without major code rewrites allows your application to grow alongside your business demands.

When choosing the framework for your project, consider the following factors:

Key factors when choosing a backend development framework
  1. Project type and goal. The Python ecosystem has dedicated frameworks for developing everything from web applications to AI algorithms. Choosing a framework that matches the type of project you are building will provide you with features you need out of the box and reduce development time.
  2. Project complexity. Your framework has to support your appโ€™s expected level of architectural complexity and flexibility. While some frameworks are designed for quick prototyping of small solutions, others enable building large products with diverse functionality.
  3. Learning curve. Although Python is considered one of the easier languages to learn, developers need to spend time researching their chosen framework. They have to know its key concepts, capabilities, and hidden issues. Having someone on the team familiar with the framework of your choice can significantly speed up research and development.
  4. Documentation and community. During development, your team will encounter unexpected issues with any framework โ€” thatโ€™s just part of the development process. Robust documentation and community support help developers spend less time searching for the solution and increase the chance that the solution will be reliable.
  5. Known vulnerabilities. Each framework comes with built-in issues and vulnerabilities you have to research and plan for when designing the appโ€™s architecture. Make sure that vulnerabilities of your chosen framework donโ€™t affect your app, or that you can easily mitigate them. 

To help you choose a relevant Python backend framework, weโ€™ll compare Flask, Django, and Tornado. Weโ€™ll create a sample application and write three versions of the back end for it, one for each framework. Then weโ€™ll compare our results in terms of development speed, security, code clarity, and maintainability.

Need to develop a reliable back end?

Entrust this task to Apriorit experts and get a robust and secure backend solution.

Drawing the application logic

To demonstrate how to use popular Python frameworks for backend development, weโ€™ll develop a sample application that stores and displays information about network packets: time of sending, packet size, and sender. Additionally, weโ€™ll implement basic user authentication, authorization, and two user roles:

  • Regular users can add packets and request information about them.
  • The administrator can add packets, request information about all packets, add and delete users, and retrieve a list of users.

The application will have a command-line interface (CLI) and backend servers. Weโ€™ll use a local SQLite 3 database to store information about network packets and application users. We can use the SQLite engine to connect the SQLAlchemy toolkit and use this toolkit to work with objects in our database. 

This is a fairly typical task with a variety of applications in data processing and data management. You can check out the full code for our application here and the backend code for all three examples here. Now, letโ€™s see how we can use Flask to build the back end for this app.

1. Backend development with Flask

Flask is a Python backend web development framework that is popular for its lightweight and minimalist code. It follows a micro-framework approach, meaning it provides the core essentials for web development without enforcing a specific project structure or tools. Flask offers features like built-in routing, request handling, and templating, allowing developers to build scalable applications while integrating only the necessary libraries. Its modularity makes it suitable for small to medium-sized projects, and its extensive ecosystem allows you to build various apps.

Letโ€™s start building our appโ€™s back end with Flask by establishing dependencies for user login. Weโ€™ll use the Flask-JWT-Extended library to generate JWT after the user logs in and use it for Bearer authorization. We also need User and Packet classes to work with data. Theyโ€™ll be accessible via blueprints in url /user and /packet.

To work with JWT, we also need to configure JWT_SECRET_KEY and create a JWTManager. To use WSGI with the HTTPS protocol, letโ€™s point the route to certificate files in the keyfile and certfile parameters.

Flask also needs a library like gevent to handle the HTTP protocol. So letโ€™s add Flask, Flask-JWT-Extended, and gevent to our app:

Python
app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = "test" # replace with your secret key
app.register_blueprint(user, url_prefix='/user')
app.register_blueprint(packet, url_prefix='/packet')
if __name__ == '__main__':    
    parser = argparse.ArgumentParser(prog="flask_interface")
    parser.add_argument("-c", "--certfile", help="path to tls certificate", type=str, required=False)
    parser.add_argument("-k", "--keyfile", help="path to tls key", type=str, required=False)
    options = parser.parse_args()
    use_https = ("certfile" in dir(options) and options.certfile and "keyfile" in dir(options) and options.keyfile)
    with backend.get_session() as session:
        backend.create_tables()
        backend.add_admin(session)
    jwt.init_app(app)
    http_server =  (
            WSGIServer("0.0.0.0:5000", app, keyfile=options.keyfile, certfile=options.certfile) 
            if use_https else 
            WSGIServer( "0.0.0.0:5000", app))
    http_server.serve_forever()

Now, letโ€™s work on the API. To allow users to log in, we need to create a POST /user/login endpoint that waits for a username and hashed password as JSON data. When the endpoint verifies that the back end can log a user in, it creates a JWT token and identifies the user by their username. This way, our app can remember a user and not force them to log in every time they use the app.

Python
    @user.route('/login', methods=['POST'])
    def login():
        # login
        username = request.json.get('username', None)
        password = request.json.get('password', None)
        if username is None or password is None:
            return jsonify({"msg": "Missing username or password"}), HTTPStatus.BAD_REQUEST
        with backend.get_session() as session:
            password = hashlib.sha256(password.encode('utf-8')).hexdigest()
            user_obj = model.User(username=username, password=password, is_admin=None)
            try:
                backend.login(user_obj, session)
                access_token = create_access_token(identity=username)
                User.blacklist.discard(access_token)
                return jsonify(access_token=access_token), HTTPStatus.OK
            except RuntimeError as ex:
                logger.error(ex)
                return jsonify({"msg": "Bad username or password"}), HTTPStatus.UNAUTHORIZED

Users also need to be able to end their session and make the JWT token invalid. Weโ€™ll add a POST /user/logout endpoint for that. At the start of the function, letโ€™s add verify_jwt_in_request() to specify that only authenticated users can interact with this endpoint. The JWT of these users will be added to the blacklist. Then, the  @jwt.token_in_blocklist_loader decorator makes Flask check that the token is blocked and log out the user.

Python
    @user.route('/logout', methods=['POST'])
    def logout():
        verify_jwt_in_request()
        # logout
        jti = get_jwt()['jti']
        # If already in blacklist, return message that the user is already logged out
        if jti in User.blacklist:
            return jsonify({"msg": "Already logged out"}), HTTPStatus.OK
        User.blacklist.add(jti)
        return jsonify({"msg": "Successfully logged out"}), HTTPStatus.OK

Other endpoints in our back end have one simple architecture:

  1. The back end requests certain parameters.
  2. The endpoints request the back end to process the parameters.
  3. The endpoints pass data retrieved from the back end as JSON to the entity that requested it.
Python
class User(object):
    blacklist = set()
    
    @user.route('', methods=['GET'])
    def get():
        verify_jwt_in_request()
        # get list of users
        with backend.get_session() as session:
            user_obj = model.User(username=get_jwt_identity(), password=None, is_admin=None)
            try:
                backend.check_admin(user_obj, session)
            except RuntimeError:
                return jsonify({"msg": "You are not admin"}), HTTPStatus.FORBIDDEN
            return jsonify([usr.to_dict() for usr in backend.list_users(session)]), HTTPStatus.OK

    @user.route('', methods=['POST'])
    def post():
        # ...
        username = request.json.get('username', None)
        password = request.json.get('password', None)
        is_admin = request.json.get('is_admin', 0)
        if username is None or password is None:
            return jsonify({"msg": "Missing username or password"}), HTTPStatus.BAD_REQUEST
        user_obj = model.User(username=username, password=password, is_admin=is_admin)
        backend.add_user(user_obj, session)
        return jsonify({"msg": "User added"}), HTTPStatus.OK
    # ...

To return an image, we save the plt output to a file with the BytesIO object, which lets us work with the file in memory without using the disk. We then send the image using the send_file function, passing the BytesIO object with the image data.

Python
    @packet.route('/plot', methods=['GET'])
    def plot():
        verify_jwt_in_request()
        # get plot
        with backend.get_session() as session:
            plt = backend.get_packet_plot(session)
            # get figure and set its size to 12in x 8 in
            fig = plt.gcf()
            fig.set_size_inches(12, 8)
            
            buf = BytesIO()
            plt.savefig(buf, format='png')
            buf.seek(0)
            # send file to client
            return send_file(buf, mimetype='image/png')
    # ...

With that, the back end part of our application is complete. Overall, the Flask code is moderately complex during setup but easy to use afterwards. You can also use SQLAlchemy with database migrations and convert data into class objects instead of Pydantic structures.

Next, letโ€™s see how to implement the same backend functionalities with Django.

Read also

Choosing an Effective Python Dependency Management Tool for Flask Microservices: Poetry vs Pip

Our client wanted to enhance VAD platform security and performance by adding new capabilities to their platform and needed quality support for existing features.

Learn more
Flask Microservices Poetry vs Pip

2. Backend development with Django

Django is a high-level minimalist Python framework for backend development and management of complex systems and infrastructures. Itโ€™s well-suited for building backend applications due to its robust ORM for database interactions, scalability, and built-in admin interface. This framework has many third-party packages for quick development.

Django provides a variety of security features, including built-in user authentication. But since our application stores user data in an existing database, we need to connect to it without Django. Letโ€™s use JWT tokens for that. As for working with Django, we only need to set up the project using the django-admin startproject django_interface command and leave the other settings unchanged.

We need to create a JWT that contains data for the username and the time when the token will expire. In this case, the tokenโ€™s lifespan is 30 minutes. Then, letโ€™s call the get_user method to ensure that the user is logged in. If the token has a valid signature, contains the username, and isnโ€™t expired or logged out, this method returns the username.

Python
import jwt
import datetime


SECRET_KEY = 'test'  # replace with your secret key

def get_token(username):
    payload = {
        'username': username,
        'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30)
    }
    token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
    return token

def check_token_blacklisted(token):
    return token not in check_token_blacklisted.blacklist
check_token_blacklisted.blacklist = set()

def blacklist_token(token):
    check_token_blacklisted.blacklist.add(token)

def get_user(request: 'django.http.request.HttpRequest'):
    # get Authorization header from django request
    token = request.META.get('HTTP_AUTHORIZATION').split()[1]
    if token in check_token_blacklisted.blacklist:
        raise RuntimeError('Token blacklisted')
    payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
    # check expiration time
    exp = payload.get('exp')
    if datetime.datetime.utcnow() > datetime.datetime.fromtimestamp(exp):
        raise RuntimeError('Token expired')
    username = payload.get('username')
    return username

Now, we can start working with Django. To register an endpoint, the framework uses a list to specify the path to the endpoint and the class that will handle requests to this endpoint. This code is located in urls.py.

Letโ€™s work on user login and logout processes next. To use any other method than GET, we need to either start with GET and take the CSRF cookie or use the csrf_exempt decorator. In the case of class-based views, csrf_exempt doesnโ€™t work, so we need to use method_decorator(csrf_exempt, name='dispatch').

Then, we create POST /login and DELETE /login endpoints that perform similar operations to the Flask interface but using the JWT utilities described above. Finally, we add the check_authorization function that verifies that the Bearer token is present and isnโ€™t blacklisted.

Python
def check_authorization(request):
    if 'HTTP_AUTHORIZATION' not in request.META:
        return HttpResponse(status=HTTPStatus.UNAUTHORIZED)
    token = request.META['HTTP_AUTHORIZATION'].split()[1]
    if not auth_with_jwt.check_token_blacklisted(token):
        return HttpResponse(json.dumps({"msg": "Token already blacklisted"}), status=HTTPStatus.BAD_REQUEST)
    return None

@method_decorator(csrf_exempt, name='dispatch')
class LoginView(View):
    def post(self, request):
        data = json.loads(request.body)
        username = data.get('username')
        password = data.get('password')
        hashed_password = hashlib.sha256(password.encode('utf-8')).hexdigest()
        with get_session() as session:
            try:
                user = model.User(username=username, password=hashed_password)
                backend.login(user, session)
                token = auth_with_jwt.get_token(username)

                return HttpResponse(json.dumps({'access_token': token}))
            except RuntimeError as ex:
                logger.error(ex)
                return HttpResponse(json.dumps({"msg": "Bad username or password"}), status=HTTPStatus.UNAUTHORIZED)
            
    def delete(self, request):
        res = check_authorization(request)
        if res is not None:
            return res
        token = request.META['HTTP_AUTHORIZATION'].split()[1]
        auth_with_jwt.blacklist_token(token)
        return HttpResponse(json.dumps({"msg": "Successfully logged out"}), status=HTTPStatus.OK)

The methods used on the Django back end are similar to the ones we implemented in the Flask back end: they receive parameters from the request, pass them to the back end, and return the results.

Python
@method_decorator(csrf_exempt, name='dispatch')
class User(View):
    def get(self, request):
        res = check_authorization(request)
        if res is not None:
            return res
        with get_session() as session:
            try: 
                user = auth_with_jwt.get_user(request)
            except RuntimeError as ex:
                return HttpResponse(json.dumps({"msg": str(ex)}), status=HTTPStatus.UNAUTHORIZED)
            try:
                backend.check_admin(user, session)
            except RuntimeError:
                return HttpResponse(json.dumps({"msg": "You are not admin"}), status=HTTPStatus.FORBIDDEN)
            users = backend.list_users(session)
            users = [user.to_dict() for user in users]
            return HttpResponse(json.dumps(users), status=HTTPStatus.OK)
    # ...

To return a result as a file, we save the results of backend.get_throughput and backend.get_packet_plot to a memory file and pass the file descriptor to return as the endpoint result.

Python
class Throughput(View):
    def get(self, request):
        res = check_authorization(request)
        if res is not None:
            return res
        with get_session() as session:
            try: 
                auth_with_jwt.get_user(request)
            except RuntimeError as ex:
                return HttpResponse(json.dumps({"msg": str(ex)}), status=HTTPStatus.UNAUTHORIZED)
            # save plt to file
            plt = backend.get_throughput(session)
            # get figure and set its size to 12in x 8in
            fig = plt.gcf()
            fig.set_size_inches(12, 8)
            buf = BytesIO() 
            plt.savefig(buf, format="png")
            buf.seek(0)
            # send file to client
            return HttpResponse(buf, content_type='image/png')
# ...

As you can see, a Django project has a unique structure for Python apps. Itโ€™s well-covered in Django documentation and other sources, but it still presents a learning curve for developers. Also, Django has unique challenges like CSRF cookie extraction, which we mentioned above. Thatโ€™s why Django may not be the best choice for developers who have no experience with it.

Finally, letโ€™s see how to develop the same app back end with Tornado.

Read also

Build Python Web Apps with Django: A Practical Guide with Code Examples

Get a closer look at Django. We analyze how this framework works, why itโ€™s worth using, and how it benefits web development using a practical example.

Learn more
Build Python Web Apps with Django: A Practical Guide with Code Examples

3. Backend development with Tornado

Tornado is one of the most popular Python frameworks designed to handle high-performance, scalable, and non-blocking applications. Its asynchronous capabilities make it ideal for applications that require real-time updates, such as network management or chat services. Tornado has a simple, flexible routing system and provides built-in support for WebSockets, making it suitable for applications with real-time communication.

Similarly to Django, Tornado includes security features like cookie-based user authentication and supports custom authentication methods. To authenticate a user, it defines the get_current_user(self) method and returns the user ID from it. We can use the tornado.web.authenticated decorator to indicate handlers that only authenticated users can use.

Letโ€™s set up Tornado with the import tornado command. Note that Tornado passess parameters like URLs and handlers to the tornado.web.Application constructor. Handlers here are separate classes in which methods like GET and POST show which method a class should use to respond to a method in an HTTP request.

Python
import tornado
import tornado.web

def main():
    application = tornado.web.Application([
        (r"/login", LoginHandler),
        (r"/logout", LogoutHandler),
        (r"/user", UserHandler),
        (r"/packet", PacketHandler),
        (r"/packet/total", GetTotalHandler),
        (r"/packet/average", GetAverageHandler),
        (r"/packet/throughput", GetThroughputHandler),
        (r"/packet/plot", GetPacketPlotHandler),
    ], cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__")
    # run server
    application.listen(5001)
    tornado.ioloop.IOLoop.current().start()

if __name__ == "__main__":
    main()

Letโ€™s now add login and logout functionalities. BaseHandler defines the get_current_user(self) method, which checks that the username cookie isnโ€™t logged out and retrieves the user identifier with username signed_cookie. Then, the LoginHandler, on a POST request, checks the username and password and creates a signed_cookie for easier user authentication in the future.

LogoutHandler also requests to delete the cookie from the client and adds the username cookie value to the list of logged-out users.

Python
class BaseHandler(tornado.web.RequestHandler):
    blacklisted_tokens = set()
    def get_current_user(self):
        if self.get_cookie("username") in BaseHandler.blacklisted_tokens:
            return None
        return self.get_signed_cookie("username").decode('utf-8')
    
class LoginHandler(BaseHandler):    
    def post(self):
        data = json.loads(self.request.body.decode('utf-8'))
        username = data.get('username', None)
        password = data.get('password', None)
        if username is None or password is None:
            self.write({"msg": "Missing username or password"})
            self.set_status(HTTPStatus.BAD_REQUEST)
            return
        # hash password with sha256
        password = hashlib.sha256(password.encode('utf-8')).hexdigest()
        user = model.User(username=username, password=password)
        with backend.get_session() as session:
            try:
                backend.login(user, session)
                self.write({"msg": "Logged in successfully"})
                self.set_signed_cookie("username", username)
            except RuntimeError as ex:
                logger.error(ex)
                self.set_status(HTTPStatus.UNAUTHORIZED)
                self.write({"msg": "Bad username or password"})


class LogoutHandler(BaseHandler):
    @tornado.web.authenticated
    def delete(self):
        BaseHandler.blacklisted_tokens.add(self.get_cookie("username"))
        self.clear_cookie("username")
        self.write({"msg": "Successfully logged out"})

Other endpoints in our Tornado-based solution also work as handlers that check whether the user is logged in and can perform the operation they want and return the result of method execution from the back end.

Python
class UserHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        with backend.get_session() as session:
            try:
                backend.check_admin(model.User(username=self.get_current_user()), session)
            except RuntimeError:
                self.write({"msg": "You are not admin"})
                self.set_status(HTTPStatus.FORBIDDEN)
                return
            users = backend.list_users(session)
            self.write(json.dumps([user.to_dict() for user in users]))
# ...

Transferring a file requires one more action than in other frameworks, as we need to read data from the buffer before adding it to the response.

Python
class GetThroughputHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        with backend.get_session() as session:
            # save plt to file
            plt = backend.get_throughput(session)
            # get figure and set its size to 12in x 8in
            fig = plt.gcf()
            fig.set_size_inches(12, 8)
            
            buf = BytesIO()
            plt.savefig(buf, format='png')
            buf.seek(0)
            content = buf.read()
            # set content type to image/png
            self.set_header('Content-Type', 'image/png')
            self.write(content)
# ...

In general, Tornado code looks more structured than Flask and requires less of a learning curve compared to Django. Letโ€™s analyze and compare these frameworks.

Comparison of results: which framework did best

All three frameworks โ€” Flask, Django, and Tornado โ€” allow us to develop a working back end, but as you can see, the code is unique for each framework. Letโ€™s compare them, rating each frameworkโ€™s properties on a scale of 1 to 5, where 1 is poor, and 5 is excellent):

Development speedCode clarity and maintainability
Application security
Flask555
Django445
Tornado341

Note that these are subjective results based on our development experience and framework performance as shown in this article.

Development speed. Flask clearly excels in coding speed, offering a simple and efficient setup that allows you to create the first endpoints in no time. Thatโ€™s why this framework is ideal for projects that need a quick start or MVP. Django, with its automated setup, gets us to endpoint creation just as fast. The heavier framework can add overhead later, since itโ€™s designed to power large-scale projects. Tornado, however, takes multiple steps to launch and execute even a basic endpoint, making it the slowest option.

Code clarity and maintainability. Flask provides a good balance with its moderate complexity and clear methods for adding and defining endpoints, which is why this framework fits projects that require simplicity and flexibility. Django offers excellent code structure, though its unique architecture can be overkill for smaller projects or those requiring a quick project start. Tornado provides a reasonable structure, making it suitable for developers who need flexibility without sacrificing clarity.

Application security. Flask supports HTTPS and offers modules for authorization and JWT authentication, which are must-haves for secure software development. Django provides even more security features, as it includes built-in HTTPS support and comprehensive authentication options. In contrast, Tornado falls behind in security, as it relies heavily on cookies and requires additional server configurations to handle HTTPS, making it a less secure option out of the box.

From our experience at Apriorit, Flask is ideal for small to medium-sized applications as it allows for a quick start to development and works well with smaller codebases. It doesnโ€™t impose significant security limitations, making Flask code usable in many contexts. Django is a better fit for long-term projects due to its more structured framework. Like Flask, it also doesnโ€™t limit itself in terms of security features. Tornado is suitable for servers that have HTTPS set up through a service like Nginx and where security requirements are not particularly stringent. 

Conclusion

Selecting the right Python framework for your backend development is a critical decision that can significantly impact your projectโ€™s success. The key is not just choosing a popular framework but finding the one that best fits your specific project requirements. Thanks to Pythonโ€™s rich ecosystem, you can find a fitting framework whether you need fast development, long-term maintainability, or enhanced security for a specific type of product. 

Aprioritโ€™s Python experts have worked with various frameworks and know their pros and cons from experience. Whether you need Python for backend development or any other task, we are ready to help you with it.

Planning a Python development project?

Leverage Aprioritโ€™s practical expertise to build an efficient and secure back end for your app.

Have a question?

Ask our expert!

Tell us about
your project

...And our team will:

  • Process your request within 1-2 business days.
  • Get back to you with an offer based on your project's scope and requirements.
  • Set a call to discuss your future project in detail and finalize the offer.
  • Sign a contract with you to start working on your project.

Do not have any specific task for us in mind but our skills seem interesting? Get a quick Apriorit intro to better understand our team capabilities.