Flask vs. Django: Why I Chose Django

Tom Harrison
8 min readJan 26, 2024

I was inspired to write this after a great conversation on Medium from an expert at Python and Django, Builescu Daniel who wrote an excellent article Top 10 Django Mistakes to Avoid and How to Fix Them. If you want to learn about Django, follow Builescu. He writes a lot, writes well, and knows his shit.

Building a Platform for ASL Signers

Over the last several months I have been designing an architecture that we expect will become a platform supporting several apps. My company, ASL Education Center is creating a library of user-interface and user-experience components that provide a UI centered around American Sign Language (ASL) communicators, and some applications that use these components.

ASL is a different language, not just a translation of English, and it is visual so what happens with text for spoken languages is done with videos of ASL signers. We’re asking: how do we build UI components and apps that support this underserved population?

Assets: Media and User Interface Components

A key part of our platform is a service to manage assets — videos of various ASL that have the right attributes for our UI library. We’ll build a strong API and backing data schema to support this content. We’ll also build a CMS allowing our users to manage their content. We’ll build several apps that provide different services to ASL signers and each will depend on the content service and the UI/UX library components. The UI will be made with React, and call our back-end APIs for content and app-specific endpoints.

This turns out to be more than a simple REST API or web API, and we learned that Flask doesn’t really support our requirements very well. This is not a criticism of Flask as much as it is a description of a few things we found as we developed the core of our platform.

Flask

We started with the latest version of Flask, using the Flask-SQLAlchemy library. To be sure it was easy to get going — the simple examples allowed us to get a simple connection to our database running in no time flat! As I extended the sample User model, I realized that there are several patterns that you might use to express the attributes of the objects backing the relational model. The latest version proposes code like this:

Initialize the app:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
pass

db = SQLAlchemy(model_class=Base)

then configure:

# create the app
app = Flask(__name__)
# configure the SQLite database, relative to the app instance folder
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///project.db"
# initialize the app with the extension
db.init_app(app)

then define models:

from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column

class User(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(unique=True)
email: Mapped[str]

But as we modified our models, a pattern like migration seems appropriate, so I added Flask-Migrate. It’s designed to work with Flask and SQLAlchemy, so off I went, and found this example:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'

db = SQLAlchemy(app)
migrate = Migrate(app, db)

class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128))

In these two cases, I realized there are several different patterns in use. For example Flask-Migrate uses a form to define a column like

id = db.Column(db.Integer, primary_key=True

where Flask-SQLAlchemy uses

id: Mapped[int] = mapped_column(primary_key=True)

No big deal, really … they both result in a User(db.Model) object. But it wasn’t clear to me if there really is a difference I should care about.

Notice also that the SQLAlchemy example has a slightly different way of initialization:

db = SQLAlchemy(model_class=Base)

# ... then later

db.init_app(app)

versus Flask-Migrate:

db = SQLAlchemy(app)
migrate = Migrate(app, db)

It turns out that the best practice is neither of these, as Flask doc suggests a factory model where objects are defined early in the package load, like this:

db = SQLAlchemy()
migrate = Migrate()

then later initialized in a function:

def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)

# Initialize Flask extensions
db.init_app(app)
migrate.init_app(app, db)

# ...
return app

So with Flask, I found three slightly different ways of setting things up, and up to me to choose which one, then make them all work together.

Decisions, decisions

It was also up to me to decide how to structure my code. It seemed to make sense to have a package for each model. I did eventually figure out how to have a single place where my models were defined … mostly. I still needed to code for each field in a model in basic templates. I also used Marshmallow to provide serialization for a REST API, and it appears that I needed to specify each field that I wanted output as JSON. I am sure there’s a simple way to avoid these and write generic templates that represent the full state of the object as it changes.

But as I left it, there are multiple places that refer to the attributes (columns, fields) of a model in several places in my code. So I need to update in several places just to add or change a column. The Alembic data migrations worked well enough, and added commands to the Flask CLI.

All of this is really fine. It took a little figuring out to make it all work, and I am ok with the directory layout I chose, and generally feel like it’s not too bad to add routes and so on. But it’s a lot of work — much struggling with getting imports to work, and I still feel like there’s a kind of circular reference in using Flask Blueprints, but it works.

Django

With Django, there’s a clear and established (“opinionated”?) pattern to follow for where code goes:

├── __init__.py
├── admin.py
├── apps.py
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_account_user_account_survey.py
│ ├── 0003_aslmedia_role_answeroption_item_question_response.py
│ └── __init__.py
├── models.py
├── serializers.py
├── tests.py
├── urls.py
└── views.py

and models.py has simple classes:

class User(models.Model):
username = models.CharField(max_length=100, unique=True, null=False)
email = models.EmailField(max_length=100, unique=True)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
password = models.CharField(max_length=100)
anonymous = models.BooleanField(default=False)
account = models.ForeignKey(Account, related_name="users", on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

def __str__(self):
return self.username

From these classes, and the addition of DjangoRESTFramework, I add a reference in serializers.py like this:

class UserSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = User
fields = '__all__'

And a view that I register with the router

class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('username')
serializer_class = UserSerializer
router.register(r'users', views.UserViewSet)

Which gives me:

  • JSON output at the endpoint (in my case api/v1/users)
$ curl -s http://localhost:8000/api/v1/users/ |
jq .
[
{
"url": "http://localhost:8000/api/v1/users/3/",
"username": "joe",
"email": "joe@asledcenter.org",
"first_name": "Joe",
"last_name": "Doe",
"password": "password",
"anonymous": false,
"created_at": "2024-01-22T15:39:18.617000Z",
"updated_at": "2024-01-22T15:39:18.617000Z",
"account": "http://localhost:8000/api/v1/accounts/2/"
},
{
"url": "http://localhost:8000/api/v1/users/2/",
"username": "tom",
"email": "tom@asledcenter.org",
"first_name": "Tom",
"last_name": "Harrison",
  • A web-browseable API (with links to relations) with Postman-like features for interacting with methods of the API in a browser
  • Automatic documentation from the DocString
  • Built-in database migrations
  • A web-UI for managing content including dropdowns for picking or adding relations, e.g. Account for my User

and

  • A command to dump some or all of my database to a JSON file fixture
  • A command to load some or all of the content from a JSON fixture into the database — awesome for team development, great for building test fixtures
  • A plugin for Swagger support
  • Built-in support for user authentication (HUGE!!)
  • A health check plugin and a command to make sure all is well
$ python manage.py health_check
Cache backend: default ... working
DatabaseBackend ... working
DefaultFileStorageHealthCheck ... working
MigrationsHealthCheck ... working

There’s really much more to Django, in particular great patterns to ensure scalabililty, environment support, simple containerization, and much much more.

Reality Check

Years ago, I also worked with Ruby on Rails, and have worked at a number of companies that used Rails (including my current company). Rails was the first framework that really embraced what Django does: an ORM with full support for all the things a modern application needs:

  • security features like CSRF, SQL injection prevention, CORS, CSP support, user auth, and so on
  • database support including migrations, optimizations, etc.
  • strong layout and file structure patterns
  • great documentation
  • great community (totally key!)

In my opinion, however, Rails went … off the rails when it tried to layer a complete JS framework on top. There were multiple iterations (CoffeeScript, Turbolinks, more) and an ever-moving set of options to support the past and embrace the present. The result was a mishmash of different strategies, even for older apps different ones at different times. A similar issue arose with test frameworks, as well.

The result is that Rails 7 is overwhelming for newcomers, and even old hands — it took me several tries to understand how to bring our existing Rails app up to the present without breaking things. This is a sign of complexity.

Complexity is the ultimate evil in software as it necessarily leads to insecure software. If you cannot easily maintain and upgrade software, it is insecure.

Has Django Avoided The Trap of Complexity?

Django, perhaps by modeling its abilities after Rails, seems to have avoided much of the layers of complexity problem, while still providing core support for really key functions, and an excellent and straightforward way to add external extensions that involves nearly no magic, and really minimal configuration.

The reality check is that Django, and perhaps Flask, are both at that critical juncture most frameworks get to where the key test of longevity is whether they can avoid adding new features, and instead ensure that the ones they have, and the external plugins or libraries continue to work (and throw helpful errors when they don’t). This is the time, in 2024, when the focus needs to be on maturity, robustness, clarity, and stability — it’s a trap to add support for whatever the new shiny thing in software is (lookin’ at you ChatGPT and LLMs in general).

My Conclusion (Yours May Be Different!)

Flask can also have many or perhaps all of the features that Django has, but as I showed at the top, it’s up to the developer to figure out how to glue them together best. That said, Flask is a great tool for building a straightforward database app. It does have a lot of great features, and can be extended over time. For an app that’s going to be doing more critical functions, Django is probably the better choice if you’re just starting out.

Honestly, I don’t have enough experience with either yet to be able to see how things work over time, and under production loads and configurations. I welcome feedback and experiences of others, in agreement or not!

--

--

Tom Harrison

30 Years of Developing Software, 20 Years of Being a Parent, 10 Years of Being Old. (Effective: 2020)