diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index e32f995..0b0ee1a 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -20,26 +20,49 @@ jobs:
         ports:
         - 5432:5432
     steps:
-    - uses: actions/checkout@v2
-    - uses: actions/setup-python@v2
+    - uses: actions/checkout@v4
+    - uses: actions/setup-python@v5
       with:
-        python-version: '3.6'
+        python-version: '3.7'
+        check-latest: true
     - name: Setup databases
       run: |
         pip install .
-        pip install mysqlclient psycopg2-binary
+        pip install mysqlclient psycopg2-binary SQLAlchemy
+
     - name: Run tests
       run: python tests/sql.py
       env:
         MYSQL_HOST: 127.0.0.1
         POSTGRESQL_HOST: 127.0.0.1
+
     - name: Install pypa/build
       run: python -m pip install build --user
+
     - name: Build a binary wheel and a source tarball
       run: python -m build --sdist --wheel --outdir dist/ .
+
     - name: Deploy to PyPI
       if: ${{ github.ref == 'refs/heads/main' }}
       uses: pypa/gh-action-pypi-publish@release/v1
       with:
         user: __token__
         password: ${{ secrets.PYPI_API_TOKEN }}
+
+    - name: Get Version
+      id: py_version
+      run: |
+        echo ::set-output name=version::$(python3 setup.py --version)
+
+    - name: Create Release
+      if: ${{ github.ref == 'refs/heads/main' }}
+      uses: actions/github-script@v7
+      with:
+        github-token: ${{ github.token }}
+        script: |
+          github.rest.repos.createRelease({
+            owner: context.repo.owner,
+            repo: context.repo.repo,
+            tag_name: "v${{ steps.py_version.outputs.version }}",
+            tag_commitish: "${{ github.sha }}"
+          })
diff --git a/.gitignore b/.gitignore
index 4286ed6..dd3ffcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 .*
 !/.github/
 !.gitignore
+build/
 *.db
 *.egg-info/
 *.pyc
diff --git a/README.md b/README.md
index cf2c62d..c94bd1f 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,5 @@
 # CS50 Library for Python
 
-[![Build Status](https://travis-ci.com/cs50/python-cs50.svg?branch=master)](https://travis-ci.org/cs50/python-cs50)
-
 ## Installation
 
 ```
diff --git a/docker-compose.yml b/docker-compose.yml
index f795750..8608080 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,7 +7,7 @@ services:
       - postgres 
     environment:
       MYSQL_HOST: mysql
-      POSTGRESQL_HOST: postgresql
+      POSTGRESQL_HOST: postgres
     links:
       - mysql
       - postgres
@@ -20,7 +20,7 @@ services:
       MYSQL_ALLOW_EMPTY_PASSWORD: yes
     healthcheck:
       test: ["CMD", "mysqladmin", "-uroot", "ping"]
-    image: cs50/mysql:8
+    image: cs50/mysql
     ports:
       - 3306:3306
   postgres:
diff --git a/setup.py b/setup.py
index d1c4f9b..1817b95 100644
--- a/setup.py
+++ b/setup.py
@@ -10,11 +10,13 @@
         "Topic :: Software Development :: Libraries :: Python Modules"
     ],
     description="CS50 library for Python",
-    install_requires=["Flask>=1.0", "SQLAlchemy", "sqlparse", "termcolor", "wheel"],
+    install_requires=["Flask>=1.0", "packaging", "SQLAlchemy>=2,<3", "sqlparse", "termcolor", "wheel"],
     keywords="cs50",
+    license="GPLv3",
+    long_description_content_type="text/markdown",
     name="cs50",
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.0.0"
+    version="9.4.0"
 )
diff --git a/src/cs50/__init__.py b/src/cs50/__init__.py
index aaec161..7dd4e17 100644
--- a/src/cs50/__init__.py
+++ b/src/cs50/__init__.py
@@ -8,6 +8,7 @@
 
 # Import cs50_*
 from .cs50 import get_char, get_float, get_int, get_string
+
 try:
     from .cs50 import get_long
 except ImportError:
diff --git a/src/cs50/cs50.py b/src/cs50/cs50.py
index 1d7b6ea..f331a88 100644
--- a/src/cs50/cs50.py
+++ b/src/cs50/cs50.py
@@ -6,7 +6,6 @@
 import re
 import sys
 
-from distutils.sysconfig import get_python_lib
 from os.path import abspath, join
 from termcolor import colored
 from traceback import format_exception
@@ -18,7 +17,9 @@
 
 try:
     # Patch formatException
-    logging.root.handlers[0].formatter.formatException = lambda exc_info: _formatException(*exc_info)
+    logging.root.handlers[
+        0
+    ].formatter.formatException = lambda exc_info: _formatException(*exc_info)
 except IndexError:
     pass
 
@@ -38,26 +39,31 @@
 _logger.addHandler(handler)
 
 
-class _flushfile():
+class _Unbuffered:
     """
     Disable buffering for standard output and standard error.
 
-    http://stackoverflow.com/a/231216
+    https://stackoverflow.com/a/107717
+    https://docs.python.org/3/library/io.html
     """
 
-    def __init__(self, f):
-        self.f = f
+    def __init__(self, stream):
+        self.stream = stream
 
-    def __getattr__(self, name):
-        return object.__getattribute__(self.f, name)
+    def __getattr__(self, attr):
+        return getattr(self.stream, attr)
 
-    def write(self, x):
-        self.f.write(x)
-        self.f.flush()
+    def write(self, b):
+        self.stream.write(b)
+        self.stream.flush()
 
+    def writelines(self, lines):
+        self.stream.writelines(lines)
+        self.stream.flush()
 
-sys.stderr = _flushfile(sys.stderr)
-sys.stdout = _flushfile(sys.stdout)
+
+sys.stderr = _Unbuffered(sys.stderr)
+sys.stdout = _Unbuffered(sys.stdout)
 
 
 def _formatException(type, value, tb):
@@ -79,19 +85,29 @@ def _formatException(type, value, tb):
             lines += line
         else:
             matches = re.search(r"^(\s*)(.*?)(\s*)$", line, re.DOTALL)
-            lines.append(matches.group(1) + colored(matches.group(2), "yellow") + matches.group(3))
+            lines.append(
+                matches.group(1)
+                + colored(matches.group(2), "yellow")
+                + matches.group(3)
+            )
     return "".join(lines).rstrip()
 
 
-sys.excepthook = lambda type, value, tb: print(_formatException(type, value, tb), file=sys.stderr)
+sys.excepthook = lambda type, value, tb: print(
+    _formatException(type, value, tb), file=sys.stderr
+)
 
 
 def eprint(*args, **kwargs):
-    raise RuntimeError("The CS50 Library for Python no longer supports eprint, but you can use print instead!")
+    raise RuntimeError(
+        "The CS50 Library for Python no longer supports eprint, but you can use print instead!"
+    )
 
 
 def get_char(prompt):
-    raise RuntimeError("The CS50 Library for Python no longer supports get_char, but you can use get_string instead!")
+    raise RuntimeError(
+        "The CS50 Library for Python no longer supports get_char, but you can use get_string instead!"
+    )
 
 
 def get_float(prompt):
@@ -135,7 +151,7 @@ def get_string(prompt):
     as line endings. If user inputs only a line ending, returns "", not None.
     Returns None upon error or no input whatsoever (i.e., just EOF).
     """
-    if type(prompt) is not str:
+    if not isinstance(prompt, str):
         raise TypeError("prompt must be of type str")
     try:
         return input(prompt)
diff --git a/src/cs50/flask.py b/src/cs50/flask.py
index 324ec30..6e38971 100644
--- a/src/cs50/flask.py
+++ b/src/cs50/flask.py
@@ -2,22 +2,31 @@
 import pkgutil
 import sys
 
+
 def _wrap_flask(f):
     if f is None:
         return
 
-    from distutils.version import StrictVersion
+    from packaging.version import Version, InvalidVersion
     from .cs50 import _formatException
 
-    if f.__version__ < StrictVersion("1.0"):
+    try:
+        if Version(f.__version__) < Version("1.0"):
+            return
+    except InvalidVersion:
         return
 
     if os.getenv("CS50_IDE_TYPE") == "online":
         from werkzeug.middleware.proxy_fix import ProxyFix
+
         _flask_init_before = f.Flask.__init__
+
         def _flask_init_after(self, *args, **kwargs):
             _flask_init_before(self, *args, **kwargs)
-            self.wsgi_app = ProxyFix(self.wsgi_app, x_proto=1)  # For HTTPS-to-HTTP proxy
+            self.wsgi_app = ProxyFix(
+                self.wsgi_app, x_proto=1
+            )  # For HTTPS-to-HTTP proxy
+
         f.Flask.__init__ = _flask_init_after
 
 
@@ -27,7 +36,7 @@ def _flask_init_after(self, *args, **kwargs):
 
 # If Flask wasn't imported
 else:
-    flask_loader = pkgutil.get_loader('flask')
+    flask_loader = pkgutil.get_loader("flask")
     if flask_loader:
         _exec_module_before = flask_loader.exec_module
 
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index b0aa94e..81905bc 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -1,3 +1,4 @@
+import sys
 import threading
 
 # Thread-local data
@@ -9,19 +10,19 @@ def _enable_logging(f):
 
     import logging
     import functools
+    import os
 
     @functools.wraps(f)
     def decorator(*args, **kwargs):
-
         # Infer whether Flask is installed
         try:
             import flask
         except ModuleNotFoundError:
             return f(*args, **kwargs)
 
-        # Enable logging
+        # Enable logging in development mode
         disabled = logging.getLogger("cs50").disabled
-        if flask.current_app:
+        if flask.current_app and os.getenv("FLASK_ENV") == "development":
             logging.getLogger("cs50").disabled = False
         try:
             return f(*args, **kwargs)
@@ -50,9 +51,14 @@ def __init__(self, url, **kwargs):
         import re
         import sqlalchemy
         import sqlalchemy.orm
-        import sqlite3
         import threading
 
+        # Temporary fix for missing sqlite3 module on the buildpack stack
+        try:
+            import sqlite3
+        except:
+            pass
+
         # Require that file already exist for SQLite
         matches = re.search(r"^sqlite:///(.+)$", url)
         if matches:
@@ -64,19 +70,30 @@ def __init__(self, url, **kwargs):
         # Create engine, disabling SQLAlchemy's own autocommit mode raising exception if back end's module not installed;
         # without isolation_level, PostgreSQL warns with "there is already a transaction in progress" for our own BEGIN and
         # "there is no transaction in progress" for our own COMMIT
-        self._engine = sqlalchemy.create_engine(url, **kwargs).execution_options(autocommit=False, isolation_level="AUTOCOMMIT")
+        self._engine = sqlalchemy.create_engine(url, **kwargs).execution_options(
+            autocommit=False, isolation_level="AUTOCOMMIT", no_parameters=True
+        )
+
+        # Avoid doubly escaping percent signs, since no_parameters=True anyway
+        # https://github.com/cs50/python-cs50/issues/171
+        self._engine.dialect.identifier_preparer._double_percents = False
 
         # Get logger
         self._logger = logging.getLogger("cs50")
 
         # Listener for connections
         def connect(dbapi_connection, connection_record):
-
             # Enable foreign key constraints
-            if type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
-                cursor = dbapi_connection.cursor()
-                cursor.execute("PRAGMA foreign_keys=ON")
-                cursor.close()
+            try:
+                if isinstance(
+                    dbapi_connection, sqlite3.Connection
+                ):  # If back end is sqlite
+                    cursor = dbapi_connection.cursor()
+                    cursor.execute("PRAGMA foreign_keys=ON")
+                    cursor.close()
+            except:
+                # Temporary fix for missing sqlite3 module on the buildpack stack
+                pass
 
         # Register listener
         sqlalchemy.event.listen(self._engine, "connect", connect)
@@ -89,7 +106,7 @@ def connect(dbapi_connection, connection_record):
         self._logger.disabled = True
         try:
             connection = self._engine.connect()
-            connection.execute("SELECT 1")
+            connection.execute(sqlalchemy.text("SELECT 1"))
             connection.close()
         except sqlalchemy.exc.OperationalError as e:
             e = RuntimeError(_parse_exception(e))
@@ -138,15 +155,35 @@ def execute(self, sql, *args, **kwargs):
         if len(args) > 0 and len(kwargs) > 0:
             raise RuntimeError("cannot pass both positional and named parameters")
 
-        # Infer command from (unflattened) statement
-        for token in statements[0]:
-            if token.ttype in [sqlparse.tokens.Keyword, sqlparse.tokens.Keyword.DDL, sqlparse.tokens.Keyword.DML]:
-                token_value = token.value.upper()
-                if token_value in ["BEGIN", "DELETE", "INSERT", "SELECT", "START", "UPDATE"]:
-                    command = token_value
-                    break
-        else:
-            command = None
+        # Infer command from flattened statement to a single string separated by spaces
+        full_statement = " ".join(
+            str(token)
+            for token in statements[0].tokens
+            if token.ttype
+            in [
+                sqlparse.tokens.Keyword,
+                sqlparse.tokens.Keyword.DDL,
+                sqlparse.tokens.Keyword.DML,
+            ]
+        )
+        full_statement = full_statement.upper()
+
+        # Set of possible commands
+        commands = {
+            "BEGIN",
+            "CREATE VIEW",
+            "DELETE",
+            "INSERT",
+            "SELECT",
+            "START",
+            "UPDATE",
+            "VACUUM",
+        }
+
+        # Check if the full_statement starts with any command
+        command = next(
+            (cmd for cmd in commands if full_statement.startswith(cmd)), None
+        )
 
         # Flatten statement
         tokens = list(statements[0].flatten())
@@ -155,10 +192,8 @@ def execute(self, sql, *args, **kwargs):
         placeholders = {}
         paramstyle = None
         for index, token in enumerate(tokens):
-
             # If token is a placeholder
             if token.ttype == sqlparse.tokens.Name.Placeholder:
-
                 # Determine paramstyle, name
                 _paramstyle, name = _parse_placeholder(token)
 
@@ -175,7 +210,6 @@ def execute(self, sql, *args, **kwargs):
 
         # If no placeholders
         if not paramstyle:
-
             # Error-check like qmark if args
             if args:
                 paramstyle = "qmark"
@@ -190,13 +224,20 @@ def execute(self, sql, *args, **kwargs):
 
         # qmark
         if paramstyle == "qmark":
-
             # Validate number of placeholders
             if len(placeholders) != len(args):
                 if len(placeholders) < len(args):
-                    raise RuntimeError("fewer placeholders ({}) than values ({})".format(_placeholders, _args))
+                    raise RuntimeError(
+                        "fewer placeholders ({}) than values ({})".format(
+                            _placeholders, _args
+                        )
+                    )
                 else:
-                    raise RuntimeError("more placeholders ({}) than values ({})".format(_placeholders, _args))
+                    raise RuntimeError(
+                        "more placeholders ({}) than values ({})".format(
+                            _placeholders, _args
+                        )
+                    )
 
             # Escape values
             for i, index in enumerate(placeholders.keys()):
@@ -204,27 +245,34 @@ def execute(self, sql, *args, **kwargs):
 
         # numeric
         elif paramstyle == "numeric":
-
             # Escape values
             for index, i in placeholders.items():
                 if i >= len(args):
-                    raise RuntimeError("missing value for placeholder (:{})".format(i + 1, len(args)))
+                    raise RuntimeError(
+                        "missing value for placeholder (:{})".format(i + 1, len(args))
+                    )
                 tokens[index] = self._escape(args[i])
 
             # Check if any values unused
             indices = set(range(len(args))) - set(placeholders.values())
             if indices:
-                raise RuntimeError("unused {} ({})".format(
-                    "value" if len(indices) == 1 else "values",
-                    ", ".join([str(self._escape(args[index])) for index in indices])))
+                raise RuntimeError(
+                    "unused {} ({})".format(
+                        "value" if len(indices) == 1 else "values",
+                        ", ".join(
+                            [str(self._escape(args[index])) for index in indices]
+                        ),
+                    )
+                )
 
         # named
         elif paramstyle == "named":
-
             # Escape values
             for index, name in placeholders.items():
                 if name not in kwargs:
-                    raise RuntimeError("missing value for placeholder (:{})".format(name))
+                    raise RuntimeError(
+                        "missing value for placeholder (:{})".format(name)
+                    )
                 tokens[index] = self._escape(kwargs[name])
 
             # Check if any keys unused
@@ -234,13 +282,20 @@ def execute(self, sql, *args, **kwargs):
 
         # format
         elif paramstyle == "format":
-
             # Validate number of placeholders
             if len(placeholders) != len(args):
                 if len(placeholders) < len(args):
-                    raise RuntimeError("fewer placeholders ({}) than values ({})".format(_placeholders, _args))
+                    raise RuntimeError(
+                        "fewer placeholders ({}) than values ({})".format(
+                            _placeholders, _args
+                        )
+                    )
                 else:
-                    raise RuntimeError("more placeholders ({}) than values ({})".format(_placeholders, _args))
+                    raise RuntimeError(
+                        "more placeholders ({}) than values ({})".format(
+                            _placeholders, _args
+                        )
+                    )
 
             # Escape values
             for i, index in enumerate(placeholders.keys()):
@@ -248,40 +303,44 @@ def execute(self, sql, *args, **kwargs):
 
         # pyformat
         elif paramstyle == "pyformat":
-
             # Escape values
             for index, name in placeholders.items():
                 if name not in kwargs:
-                    raise RuntimeError("missing value for placeholder (%{}s)".format(name))
+                    raise RuntimeError(
+                        "missing value for placeholder (%{}s)".format(name)
+                    )
                 tokens[index] = self._escape(kwargs[name])
 
             # Check if any keys unused
             keys = kwargs.keys() - placeholders.values()
             if keys:
-                raise RuntimeError("unused {} ({})".format(
-                    "value" if len(keys) == 1 else "values",
-                    ", ".join(keys)))
+                raise RuntimeError(
+                    "unused {} ({})".format(
+                        "value" if len(keys) == 1 else "values", ", ".join(keys)
+                    )
+                )
 
         # For SQL statements where a colon is required verbatim, as within an inline string, use a backslash to escape
         # https://docs.sqlalchemy.org/en/13/core/sqlelement.html?highlight=text#sqlalchemy.sql.expression.text
         for index, token in enumerate(tokens):
-
             # In string literal
             # https://www.sqlite.org/lang_keywords.html
-            if token.ttype in [sqlparse.tokens.Literal.String, sqlparse.tokens.Literal.String.Single]:
-                token.value = re.sub("(^'|\s+):", r"\1\:", token.value)
+            if token.ttype in [
+                sqlparse.tokens.Literal.String,
+                sqlparse.tokens.Literal.String.Single,
+            ]:
+                token.value = re.sub(r"(^'|\s+):", r"\1\:", token.value)
 
             # In identifier
             # https://www.sqlite.org/lang_keywords.html
             elif token.ttype == sqlparse.tokens.Literal.String.Symbol:
-                token.value = re.sub("(^\"|\s+):", r"\1\:", token.value)
+                token.value = re.sub(r'(^"|\s+):', r"\1\:", token.value)
 
         # Join tokens into statement
         statement = "".join([str(token) for token in tokens])
 
         # If no connection yet
         if not hasattr(_data, self._name()):
-
             # Connect to database
             setattr(_data, self._name(), self._engine.connect())
 
@@ -291,9 +350,12 @@ def execute(self, sql, *args, **kwargs):
         # Disconnect if/when a Flask app is torn down
         try:
             import flask
+
             assert flask.current_app
+
             def teardown_appcontext(exception):
                 self._disconnect()
+
             if teardown_appcontext not in flask.current_app.teardown_appcontext_funcs:
                 flask.current_app.teardown_appcontext(teardown_appcontext)
         except (ModuleNotFoundError, AssertionError):
@@ -301,18 +363,23 @@ def teardown_appcontext(exception):
 
         # Catch SQLAlchemy warnings
         with warnings.catch_warnings():
-
             # Raise exceptions for warnings
             warnings.simplefilter("error")
 
             # Prepare, execute statement
             try:
-
                 # Join tokens into statement, abbreviating binary data as <class 'bytes'>
-                _statement = "".join([str(bytes) if token.ttype == sqlparse.tokens.Other else str(token) for token in tokens])
+                _statement = "".join(
+                    [
+                        str(bytes)
+                        if token.ttype == sqlparse.tokens.Other
+                        else str(token)
+                        for token in tokens
+                    ]
+                )
 
                 # Check for start of transaction
-                if command in ["BEGIN", "START"]:
+                if command in ["BEGIN", "START", "VACUUM"]:  # cannot VACUUM from within a transaction
                     self._autocommit = False
 
                 # Execute statement
@@ -323,7 +390,7 @@ def teardown_appcontext(exception):
                     connection.execute(sqlalchemy.text("COMMIT"))
 
                 # Check for end of transaction
-                if command in ["COMMIT", "ROLLBACK"]:
+                if command in ["COMMIT", "ROLLBACK", "VACUUM"]:  # cannot VACUUM from within a transaction
                     self._autocommit = True
 
                 # Return value
@@ -331,19 +398,17 @@ def teardown_appcontext(exception):
 
                 # If SELECT, return result set as list of dict objects
                 if command == "SELECT":
-
                     # Coerce types
-                    rows = [dict(row) for row in result.fetchall()]
+                    rows = [dict(row) for row in result.mappings().all()]
                     for row in rows:
                         for column in row:
-
                             # Coerce decimal.Decimal objects to float objects
                             # https://groups.google.com/d/msg/sqlalchemy/0qXMYJvq8SA/oqtvMD9Uw-kJ
-                            if type(row[column]) is decimal.Decimal:
+                            if isinstance(row[column], decimal.Decimal):
                                 row[column] = float(row[column])
 
                             # Coerce memoryview objects (as from PostgreSQL's bytea columns) to bytes
-                            elif type(row[column]) is memoryview:
+                            elif isinstance(row[column], memoryview):
                                 row[column] = bytes(row[column])
 
                     # Rows to be returned
@@ -351,15 +416,15 @@ def teardown_appcontext(exception):
 
                 # If INSERT, return primary key value for a newly inserted row (or None if none)
                 elif command == "INSERT":
-
                     # If PostgreSQL
                     if self._engine.url.get_backend_name() == "postgresql":
-
                         # Return LASTVAL() or NULL, avoiding
                         # "(psycopg2.errors.ObjectNotInPrerequisiteState) lastval is not yet defined in this session",
                         # a la https://stackoverflow.com/a/24186770/5156190;
                         # cf. https://www.psycopg.org/docs/errors.html re 55000
-                        result = connection.execute("""
+                        result = connection.execute(
+                            sqlalchemy.text(
+                                """
                             CREATE OR REPLACE FUNCTION _LASTVAL()
                             RETURNS integer LANGUAGE plpgsql
                             AS $$
@@ -371,7 +436,9 @@ def teardown_appcontext(exception):
                                 END;
                             END $$;
                             SELECT _LASTVAL();
-                        """)
+                        """
+                            )
+                        )
                         ret = result.first()[0]
 
                     # If not PostgreSQL
@@ -382,24 +449,33 @@ def teardown_appcontext(exception):
                 elif command in ["DELETE", "UPDATE"]:
                     ret = result.rowcount
 
-            # If constraint violated, return None
+                # If CREATE VIEW, return True
+                elif command == "CREATE VIEW":
+                    ret = True
+
+            # If constraint violated
             except sqlalchemy.exc.IntegrityError as e:
-                self._logger.debug(termcolor.colored(statement, "yellow"))
+                if self._autocommit:
+                    connection.execute(sqlalchemy.text("ROLLBACK"))
+                self._logger.error(termcolor.colored(_statement, "red"))
                 e = ValueError(e.orig)
                 e.__cause__ = None
                 raise e
 
             # If user error
-            except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.ProgrammingError) as e:
+            except (
+                sqlalchemy.exc.OperationalError,
+                sqlalchemy.exc.ProgrammingError,
+            ) as e:
                 self._disconnect()
-                self._logger.debug(termcolor.colored(statement, "red"))
+                self._logger.error(termcolor.colored(_statement, "red"))
                 e = RuntimeError(e.orig)
                 e.__cause__ = None
                 raise e
 
             # Return value
             else:
-                self._logger.debug(termcolor.colored(_statement, "green"))
+                self._logger.info(termcolor.colored(_statement, "green"))
                 if self._autocommit:  # Don't stay connected unnecessarily
                     self._disconnect()
                 return ret
@@ -415,75 +491,99 @@ def _escape(self, value):
         import sqlparse
 
         def __escape(value):
-
             # Lazily import
             import datetime
             import sqlalchemy
 
             # bool
-            if type(value) is bool:
+            if isinstance(value, bool):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
-                    sqlalchemy.types.Boolean().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.types.Boolean().literal_processor(self._engine.dialect)(
+                        value
+                    ),
+                )
 
             # bytes
-            elif type(value) is bytes:
+            elif isinstance(value, bytes):
                 if self._engine.url.get_backend_name() in ["mysql", "sqlite"]:
-                    return sqlparse.sql.Token(sqlparse.tokens.Other, f"x'{value.hex()}'")  # https://dev.mysql.com/doc/refman/8.0/en/hexadecimal-literals.html
+                    return sqlparse.sql.Token(
+                        sqlparse.tokens.Other, f"x'{value.hex()}'"
+                    )  # https://dev.mysql.com/doc/refman/8.0/en/hexadecimal-literals.html
                 elif self._engine.url.get_backend_name() == "postgresql":
-                    return sqlparse.sql.Token(sqlparse.tokens.Other, f"'\\x{value.hex()}'")  # https://dba.stackexchange.com/a/203359
+                    return sqlparse.sql.Token(
+                        sqlparse.tokens.Other, f"'\\x{value.hex()}'"
+                    )  # https://dba.stackexchange.com/a/203359
                 else:
                     raise RuntimeError("unsupported value: {}".format(value))
 
-            # datetime.date
-            elif type(value) is datetime.date:
+            # datetime.datetime
+            elif isinstance(value, datetime.datetime):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
-                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(value.strftime("%Y-%m-%d")))
+                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(
+                        value.strftime("%Y-%m-%d %H:%M:%S")
+                    ),
+                )
 
-            # datetime.datetime
-            elif type(value) is datetime.datetime:
+            # datetime.date
+            elif isinstance(value, datetime.date):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
-                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(value.strftime("%Y-%m-%d %H:%M:%S")))
+                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(
+                        value.strftime("%Y-%m-%d")
+                    ),
+                )
 
             # datetime.time
-            elif type(value) is datetime.time:
+            elif isinstance(value, datetime.time):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
-                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(value.strftime("%H:%M:%S")))
+                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(
+                        value.strftime("%H:%M:%S")
+                    ),
+                )
 
             # float
-            elif type(value) is float:
+            elif isinstance(value, float):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
-                    sqlalchemy.types.Float().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.types.Float().literal_processor(self._engine.dialect)(
+                        value
+                    ),
+                )
 
             # int
-            elif type(value) is int:
+            elif isinstance(value, int):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
-                    sqlalchemy.types.Integer().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.types.Integer().literal_processor(self._engine.dialect)(
+                        value
+                    ),
+                )
 
             # str
-            elif type(value) is str:
+            elif isinstance(value, str):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
-                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(
+                        value
+                    ),
+                )
 
             # None
             elif value is None:
-                return sqlparse.sql.Token(
-                    sqlparse.tokens.Keyword,
-                    sqlalchemy.types.NullType().literal_processor(self._engine.dialect)(value))
+                return sqlparse.sql.Token(sqlparse.tokens.Keyword, sqlalchemy.null())
 
             # Unsupported value
             else:
                 raise RuntimeError("unsupported value: {}".format(value))
 
         # Escape value(s), separating with commas as needed
-        if type(value) in [list, tuple]:
-            return sqlparse.sql.TokenList(sqlparse.parse(", ".join([str(__escape(v)) for v in value])))
+        if isinstance(value, (list, tuple)):
+            return sqlparse.sql.TokenList(
+                sqlparse.parse(", ".join([str(__escape(v)) for v in value]))
+            )
         else:
             return __escape(value)
 
@@ -495,7 +595,9 @@ def _parse_exception(e):
     import re
 
     # MySQL
-    matches = re.search(r"^\(_mysql_exceptions\.OperationalError\) \(\d+, \"(.+)\"\)$", str(e))
+    matches = re.search(
+        r"^\(_mysql_exceptions\.OperationalError\) \(\d+, \"(.+)\"\)$", str(e)
+    )
     if matches:
         return matches.group(1)
 
@@ -521,7 +623,10 @@ def _parse_placeholder(token):
     import sqlparse
 
     # Validate token
-    if not isinstance(token, sqlparse.sql.Token) or token.ttype != sqlparse.tokens.Name.Placeholder:
+    if (
+        not isinstance(token, sqlparse.sql.Token)
+        or token.ttype != sqlparse.tokens.Name.Placeholder
+    ):
         raise TypeError()
 
     # qmark
diff --git a/tests/foo.py b/tests/foo.py
index 7f32a00..2cf74e9 100644
--- a/tests/foo.py
+++ b/tests/foo.py
@@ -5,23 +5,25 @@
 
 import cs50
 
-"""
 db = cs50.SQL("sqlite:///foo.db")
 
 logging.getLogger("cs50").disabled = False
+logging.getLogger("cs50").setLevel(logging.ERROR)
 
-#db.execute("SELECT ? FROM ? ORDER BY ?", "a", "tbl", "c")
-db.execute("CREATE TABLE IF NOT EXISTS bar (firstname STRING)")
+db.execute("DROP TABLE IF EXISTS bar")
+db.execute("CREATE TABLE bar (firstname STRING UNIQUE)")
 
+db.execute("INSERT INTO bar VALUES (?)", None)
 db.execute("INSERT INTO bar VALUES (?)", "baz")
 db.execute("INSERT INTO bar VALUES (?)", "qux")
 db.execute("SELECT * FROM bar WHERE firstname IN (?)", ("baz", "qux"))
-db.execute("DELETE FROM bar")
+print(db.execute("SELECT * FROM bar"))
+#db.execute("DELETE FROM bar")
+
 """
 
 db = cs50.SQL("postgresql://postgres@localhost/test")
 
-"""
 print(db.execute("DROP TABLE IF EXISTS cs50"))
 print(db.execute("CREATE TABLE cs50 (id SERIAL PRIMARY KEY, val VARCHAR(16), bin BYTEA)"))
 print(db.execute("INSERT INTO cs50 (val) VALUES('foo')"))
@@ -31,7 +33,6 @@
 print(db.execute("CREATE TABLE cs50 (val VARCHAR(16), bin BYTEA)"))
 print(db.execute("INSERT INTO cs50 (val) VALUES('foo')"))
 print(db.execute("SELECT * FROM cs50"))
-"""
 
 print(db.execute("DROP TABLE IF EXISTS cs50"))
 print(db.execute("CREATE TABLE cs50 (id SERIAL PRIMARY KEY, val VARCHAR(16), bin BYTEA)"))
@@ -46,3 +47,5 @@
     pass
 print(db.execute("INSERT INTO cs50 (val) VALUES('qux')"))
 #print(db.execute("DELETE FROM cs50"))
+
+"""
diff --git a/tests/sql.py b/tests/sql.py
index 968f98b..bb37fd9 100644
--- a/tests/sql.py
+++ b/tests/sql.py
@@ -138,6 +138,12 @@ def test_lastrowid(self):
         self.assertEqual(self.db.execute("INSERT INTO foo (firstname, lastname) VALUES('firstname', 'lastname')"), 1)
         self.assertRaises(ValueError, self.db.execute, "INSERT INTO foo (id, firstname, lastname) VALUES(1, 'firstname', 'lastname')")
 
+    def test_url(self):
+        url = "https://www.amazon.es/Desesperaci%C3%B3n-BEST-SELLER-Stephen-King/dp/8497595890"
+        self.db.execute("CREATE TABLE foo(id SERIAL PRIMARY KEY, url TEXT)")
+        self.db.execute("INSERT INTO foo (url) VALUES(?)", url)
+        self.assertEqual(self.db.execute("SELECT url FROM foo")[0]["url"], url)
+
     def tearDown(self):
         self.db.execute("DROP TABLE cs50")
         self.db.execute("DROP TABLE IF EXISTS foo")
@@ -330,7 +336,7 @@ def test_cte(self):
 if __name__ == "__main__":
     suite = unittest.TestSuite([
         unittest.TestLoader().loadTestsFromTestCase(SQLiteTests),
-        #unittest.TestLoader().loadTestsFromTestCase(MySQLTests),
+        unittest.TestLoader().loadTestsFromTestCase(MySQLTests),
         unittest.TestLoader().loadTestsFromTestCase(PostgresTests)
     ])