diff --git a/.gitignore b/.gitignore
index f75ca9b..5a13495 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,6 @@
 !.gitignore
 !.travis.yml
 dist/
+*.db
 *.egg-info/
+*.pyc
diff --git a/.travis.yml b/.travis.yml
index b766e01..a20df42 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,44 +1,32 @@
 language: python
-
-python: "3.4"
-
-# build sdist
-script: make build
-
-# install twine for uploading to PyPI
-before_deploy: pip install twine
-
+python:
+- '2.7'
+- '3.4'
+branches:
+  except: "/^v\\d/"
+services:
+- mysql
+- postgresql
+install:
+- python setup.py install
+- pip install mysqlclient
+- pip install psycopg2
+before_script:
+- mysql -e 'CREATE DATABASE IF NOT EXISTS test;'
+- psql -c 'create database test;' -U postgres
+script: python tests/sqltests.py
 deploy:
-
-  # create github release
-  - provider: releases
-
-    # GitHub access token
-    api_key:
-      secure: "pwBn1lky58GHp1qR4i0oSZyOJkGMTvRzt0EfESVtl2ZbRTVUE7UFQbk/cL1002zMOUkJ4c5IjRCg95NJZWUvOSHwt4cOyqDVzi/S6AqACOlOhfnkw3S6oLGgRT3IlXK2ng0Viiqc1+BlVdwwXURKyobirqFgr4MAlb5kh75WmV9Xs4GwIS+qPq9luv38Bls2US/mNt5KRV1DiePr2ZSCqFESFfoIz+QKhZtVdynEF4jJwevEwP4HrCoT3guIJlXcWhOG37n+e8S4YLwg+k3yYeQTmR/QMgjmQLwEBZ6v9bNjqXM3CMtn3KUryDzcp5Z5+Vv1p1uoDbmuK+Ll5nQttAp/gARk+IWZ/xWc8MuQpFvjRzafbtPiF7ZlqaYh1wCREuZTWDAk/UJgQxb81v0jo0iAPyk9HMfgK2CJuU8wDwraKZ5dKk4y45Zww1gSSzpJJ8xSrylKPn7Wnft617Lnc+O0X6DnIAFtHDAPu/lPFaaokn1TN9AOPXoxb2cEeh+oDcUQD4zZG6Ukvh9+Hw8XiFBG+jEm6ekCvawTjnlZmBIw8YPJKEjrZv8LWfKhnVebRbmehawmnrZxUALCp39EjrcsIltYw4gefbd/Z9kIr8r3yVZfuq7U6vd8PBuCiDZHlKM1Lz4Ns24WK96nYe6V9Lt3WUERh6xt8JtuFrNHBiQ="
-
-    # enable wildcards in filenames
-    file_glob: true
-
-    # upload sdist
-    file: dist/*
-
-    # avoid stashing sdist
-    skip_cleanup: true
-
-    # create releases on tags only
-    on:
-      tags: true
-
-  # deploy to PyPI
-  - provider: script
-
-    # upload sdist to PyPI
-    script: twine upload -u $PYPI_USERNAME -p $PYPI_PASSWORD dist/*
-
-    # avoid stashing sdist
-    skip_cleanup: true
-
-    # deploy on tags only
-    on:
-      tags: true
+- provider: script
+  script: 'curl --fail --data "{ \"tag_name\": \"v$(python setup.py --version)\",
+    \"target_commitish\": \"$TRAVIS_COMMIT\", \"name\": \"v$(python setup.py --version)\"
+    }" --user bot50:$GITHUB_TOKEN https://api.github.com/repos/$TRAVIS_REPO_SLUG/releases'
+  on:
+    branch: master
+- provider: pypi
+  user: "$PYPI_USERNAME"
+  password: "$PYPI_PASSWORD"
+  on:
+    branch: master
+notifications:
+  slack:
+    secure: lJklhcBVjDT6KzUNa3RFHXdXSeH7ytuuGrkZ5ZcR72CXMoTf2pMJTzPwRLWOp6lCSdDC9Y8MWLrcg/e33dJga4Jlp9alOmWqeqesaFjfee4st8vAsgNbv8/RajPH1gD2bnkt8oIwUzdHItdb5AucKFYjbH2g0d8ndoqYqUeBLrnsT1AP5G/Vi9OHC9OWNpR0FKaZIJE0Wt52vkPMH3sV2mFeIskByPB+56U5y547mualKxn61IVR/dhYBEtZQJuSvnwKHPOn9Pkk7cCa+SSSeTJ4w5LboY8T17otaYNauXo46i1bKIoGiBcCcrJyQHHiPQmcq/YU540MC5Wzt9YXUycmJzRi347oyQeDee27wV3XJlWMXuuhbtJiKCFny7BTQ160VATlj/dbwIzN99Ra6/BtTumv/6LyTdKIuVjdAkcN8dtdDW1nlrQ29zuPNCcXXzJ7zX7kQaOCUV1c2OrsbiH/0fE9nknUORn97txqhlYVi0QMS7764wFo6kg0vpmFQRkkQySsJl+TmgcZ01AlsJc2EMMWVuaj9Af9JU4/4yalqDiXIh1fOYYUZnLfOfWS+MsnI+/oLfqJFyMbrsQQTIjs+kTzbiEdhd2R4EZgusU/xRFWokS2NAvahexrRhRQ6tpAI+LezPrkNOR3aHiykBf+P9BkUa0wPp6V2Ayc6q0=
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 415b084..0000000
--- a/Makefile
+++ /dev/null
@@ -1,22 +0,0 @@
-.PHONY: build
-build: clean
-	python setup.py sdist
-
-.PHONY: clean
-clean:
-	rm -rf *.egg-info dist
-
-.PHONY: install
-install: build
-	pip install dist/*.tar.gz
-
-.PHONY: push
-push:
-	git push origin "v$$(python setup.py --version)"
-
-.PHONY: release
-release: tag push
-
-.PHONY: tag
-tag:
-	git tag "v$$(python setup.py --version)"
diff --git a/README.md b/README.md
index 3f51734..fa498f1 100644
--- a/README.md
+++ b/README.md
@@ -4,19 +4,11 @@
 
 Supports Python 2 and Python 3.
 
-## Development
-
-Requires [Docker Engine](https://docs.docker.com/engine/installation/).
-
-    make bash
-    make deb # builds .deb
-
 ## Installation
 
-1. Download the latest release per https://github.com/cs50/python-cs50/releases
-1. Extract `python-cs50-*`
-1. `cd python-cs50-*`
-1. `make install`
+```
+pip install cs50
+```
 
 ## Usage
 
@@ -31,8 +23,6 @@ Requires [Docker Engine](https://docs.docker.com/engine/installation/).
     s = cs50.get_string();
 
 ## TODO
-
-* Add install target to Makefile.
 * Conditionally install for Python 2 and/or Python 3.
 * Add targets for `pacman`, `rpm`.
 * Add tests.
diff --git a/cs50/cs50.py b/cs50/cs50.py
deleted file mode 100644
index 657f405..0000000
--- a/cs50/cs50.py
+++ /dev/null
@@ -1,81 +0,0 @@
-from __future__ import print_function
-import re
-import sys
-
-class flushfile():
-    """
-    Disable buffering for standard output and standard error.
-
-    http://stackoverflow.com/a/231216
-    """
-    def __init__(self, f):
-        self.f = f
-
-    def __getattr__(self, name):
-        return object.__getattribute__(self.f, name)
-
-    def write(self, x):
-        self.f.write(x)
-        self.f.flush()
-sys.stderr = flushfile(sys.stderr)
-sys.stdout = flushfile(sys.stdout)
-
-def get_char():
-    """Read a line of text from standard input and return the equivalent char."""
-    while True:
-        s = get_string()
-        if s is None:
-            return None
-        if len(s) == 1:
-            return s[0]
-        print("Retry: ", end="")
-
-def get_float():
-    """Read a line of text from standard input and return the equivalent float."""
-    while True:
-        s = get_string()
-        if s is None:
-            return None
-        if len(s) > 0 and re.search(r"^[+-]?\d*(?:\.\d*)?$", s):
-            try:
-                return float(s)
-            except ValueError:
-                pass
-        print("Retry: ", end="")
-
-def get_int():
-    """Read a line of text from standard input and return the equivalent int."""
-    while True:
-        s = get_string();
-        if s is None:
-            return None
-        if re.search(r"^[+-]?\d+$", s):
-            try:
-                i = int(s, 10)
-                if type(i) is int: # could become long in Python 2
-                    return i
-            except ValueError:
-                pass
-        print("Retry: ", end="")
-
-if sys.version_info.major != 3:
-    def get_long():
-        """Read a line of text from standard input and return the equivalent long."""
-        while True:
-            s = get_string();
-            if s is None:
-                return None
-            if re.search(r"^[+-]?\d+$", s):
-                try:
-                    return long(s, 10)
-                except ValueError:
-                    pass
-            print("Retry: ", end="")
-
-def get_string():
-    """Read a line of text from standard input and return it as a string."""
-    try:
-        s = sys.stdin.readline()
-        return re.sub(r"(?:\r|\r\n|\n)$", "", s)
-    except ValueError:
-        return None
diff --git a/cs50/sql.py b/cs50/sql.py
deleted file mode 100644
index 2064133..0000000
--- a/cs50/sql.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import sqlalchemy
-
-class SQL(object):
-    """Wrap SQLAlchemy to provide a simple SQL API."""
-
-    def __init__(self, url):
-        """
-        Create instance of sqlalchemy.engine.Engine.
-
-        URL should be a string that indicates database dialect and connection arguments.
-
-        http://docs.sqlalchemy.org/en/latest/core/engines.html#sqlalchemy.create_engine
-        """
-        try:
-            self.engine = sqlalchemy.create_engine(url)
-        except Exception as e:
-            raise RuntimeError(e)
-
-    def execute(self, text, *multiparams, **params):
-        """
-        Execute a SQL statement.
-        """
-        try:
-
-            # bind parameters before statement reaches database, so that bound parameters appear in exceptions
-            # http://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.expression.text
-            # https://groups.google.com/forum/#!topic/sqlalchemy/FfLwKT1yQlg
-            # http://docs.sqlalchemy.org/en/latest/core/connections.html#sqlalchemy.engine.Engine.execute
-            # http://docs.sqlalchemy.org/en/latest/faq/sqlexpressions.html#how-do-i-render-sql-expressions-as-strings-possibly-with-bound-parameters-inlined
-            statement = sqlalchemy.text(text).bindparams(*multiparams, **params)
-            result = self.engine.execute(str(statement.compile(compile_kwargs={"literal_binds": True})))
-
-            # if SELECT (or INSERT with RETURNING), return result set as list of dict objects
-            if result.returns_rows:
-                rows = result.fetchall()
-                return [dict(row) for row in rows]
-
-            # if INSERT, return primary key value for a newly inserted row
-            elif result.lastrowid is not None:
-                return result.lastrowid
-
-            # if DELETE or UPDATE (or INSERT without RETURNING), return number of rows matched
-            else:
-                return result.rowcount
-
-        # if constraint violated, return None
-        except sqlalchemy.exc.IntegrityError:
-            return None
-
-        # else raise error
-        except Exception as e:
-            raise RuntimeError(e)
diff --git a/setup.py b/setup.py
index cee2266..8440a86 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,8 @@
     install_requires=["SQLAlchemy"],
     keywords="cs50",
     name="cs50",
+    package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="1.3.0"
+    version="2.0.0"
 )
diff --git a/cs50/__init__.py b/src/cs50/__init__.py
similarity index 100%
rename from cs50/__init__.py
rename to src/cs50/__init__.py
diff --git a/src/cs50/cs50.py b/src/cs50/cs50.py
new file mode 100644
index 0000000..5661aa9
--- /dev/null
+++ b/src/cs50/cs50.py
@@ -0,0 +1,130 @@
+from __future__ import print_function
+import inspect
+import re
+import sys
+
+class flushfile():
+    """
+    Disable buffering for standard output and standard error.
+
+    http://stackoverflow.com/a/231216
+    """
+    def __init__(self, f):
+        self.f = f
+
+    def __getattr__(self, name):
+        return object.__getattribute__(self.f, name)
+
+    def write(self, x):
+        self.f.write(x)
+        self.f.flush()
+sys.stderr = flushfile(sys.stderr)
+sys.stdout = flushfile(sys.stdout)
+
+def eprint(*args, **kwargs):
+    """
+    Print an error message to standard error, prefixing it with
+    file name and line number from which method was called.
+    """
+    end = kwargs.get("end", "\n")
+    sep = kwargs.get("sep", " ")
+    (filename, lineno) = inspect.stack()[1][1:3]
+    print("{}:{}: ".format(filename, lineno), end="")
+    print(*args, end=end, file=sys.stderr, sep=sep)
+
+def get_char(prompt=None):
+    """
+    Read a line of text from standard input and return the equivalent char;
+    if text is not a single char, user is prompted to retry. If line can't
+    be read, return None.
+    """
+    while True:
+        s = get_string(prompt)
+        if s is None:
+            return None
+        if len(s) == 1:
+            return s[0]
+
+        # temporarily here for backwards compatibility
+        if prompt is None:
+            print("Retry: ", end="")
+
+def get_float(prompt=None):
+    """
+    Read a line of text from standard input and return the equivalent float
+    as precisely as possible; if text does not represent a double, user is
+    prompted to retry. If line can't be read, return None.
+    """
+    while True:
+        s = get_string(prompt)
+        if s is None:
+            return None
+        if len(s) > 0 and re.search(r"^[+-]?\d*(?:\.\d*)?$", s):
+            try:
+                return float(s)
+            except ValueError:
+                pass
+
+        # temporarily here for backwards compatibility
+        if prompt is None:
+            print("Retry: ", end="")
+
+def get_int(prompt=None):
+    """
+    Read a line of text from standard input and return the equivalent int;
+    if text does not represent an int, user is prompted to retry. If line
+    can't be read, return None.
+    """
+    while True:
+        s = get_string(prompt);
+        if s is None:
+            return None
+        if re.search(r"^[+-]?\d+$", s):
+            try:
+                i = int(s, 10)
+                if type(i) is int: # could become long in Python 2
+                    return i
+            except ValueError:
+                pass
+
+        # temporarily here for backwards compatibility
+        if prompt is None:
+            print("Retry: ", end="")
+
+if sys.version_info.major != 3:
+    def get_long(prompt=None):
+        """
+        Read a line of text from standard input and return the equivalent long;
+        if text does not represent a long, user is prompted to retry. If line
+        can't be read, return None.
+        """
+        while True:
+            s = get_string(prompt)
+            if s is None:
+                return None
+            if re.search(r"^[+-]?\d+$", s):
+                try:
+                    return long(s, 10)
+                except ValueError:
+                    pass
+
+            # temporarily here for backwards compatibility
+            if prompt is None:
+                print("Retry: ", end="")
+
+def get_string(prompt=None):
+    """
+    Read a line of text from standard input and return it as a string,
+    sans trailing line ending. Supports CR (\r), LF (\n), and CRLF (\r\n)
+    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).
+    """
+    try:
+        if prompt is not None:
+            print(prompt, end="")
+        s = sys.stdin.readline()
+        if not s:
+            return None
+        return re.sub(r"(?:\r|\r\n|\n)$", "", s)
+    except ValueError:
+        return None
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
new file mode 100644
index 0000000..bfa79f0
--- /dev/null
+++ b/src/cs50/sql.py
@@ -0,0 +1,141 @@
+import datetime
+import importlib
+import logging
+import re
+import sqlalchemy
+import sys
+import warnings
+
+class SQL(object):
+    """Wrap SQLAlchemy to provide a simple SQL API."""
+
+    def __init__(self, url, **kwargs):
+        """
+        Create instance of sqlalchemy.engine.Engine.
+
+        URL should be a string that indicates database dialect and connection arguments.
+
+        http://docs.sqlalchemy.org/en/latest/core/engines.html#sqlalchemy.create_engine
+        http://docs.sqlalchemy.org/en/latest/dialects/index.html
+        """
+
+        # log statements to standard error
+        logging.basicConfig(level=logging.DEBUG)
+        self.logger = logging.getLogger(__name__)
+
+        # create engine, raising exception if back end's module not installed
+        self.engine = sqlalchemy.create_engine(url, **kwargs)
+
+    def execute(self, text, **params):
+        """
+        Execute a SQL statement.
+        """
+
+        class UserDefinedType(sqlalchemy.TypeDecorator):
+            """
+            Add support for expandable values, a la https://bitbucket.org/zzzeek/sqlalchemy/issues/3953/expanding-parameter.
+            """
+            impl = sqlalchemy.types.UserDefinedType
+            def process_literal_param(self, value, dialect):
+                """Receive a literal parameter value to be rendered inline within a statement."""
+                def process(value):
+                    """Render a literal value, escaping as needed."""
+
+                    # bool
+                    if isinstance(value, bool):
+                        return sqlalchemy.types.Boolean().literal_processor(dialect)(value)
+
+                    # datetime.date
+                    elif isinstance(value, datetime.date):
+                        return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%Y-%m-%d"))
+
+                    # datetime.datetime
+                    elif isinstance(value, datetime.datetime):
+                        return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%Y-%m-%d %H:%M:%S"))
+
+                    # datetime.time
+                    elif isinstance(value, datetime.time):
+                        return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%H:%M:%S"))
+
+                    # float
+                    elif isinstance(value, float):
+                        return sqlalchemy.types.Float().literal_processor(dialect)(value)
+
+                    # int
+                    elif isinstance(value, int):
+                        return sqlalchemy.types.Integer().literal_processor(dialect)(value)
+
+                    # long
+                    elif sys.version_info.major != 3 and isinstance(value, long):
+                        return sqlalchemy.types.Integer().literal_processor(dialect)(value)
+
+                    # str
+                    elif isinstance(value, str):
+                        return sqlalchemy.types.String().literal_processor(dialect)(value)
+
+                    # None
+                    elif isinstance(value, sqlalchemy.sql.elements.Null):
+                        return sqlalchemy.types.NullType().literal_processor(dialect)(value)
+
+                    # unsupported value
+                    raise RuntimeError("unsupported value")
+
+                # process value(s), separating with commas as needed
+                if type(value) is list:
+                    return ", ".join([process(v) for v in value])
+                else:
+                    return process(value)
+
+        # raise exceptions for warnings
+        warnings.filterwarnings("error")
+
+        # prepare, execute statement
+        try:
+
+            # construct a new TextClause clause
+            statement = sqlalchemy.text(text)
+
+            # iterate over parameters
+            for key, value in params.items():
+
+                # translate None to NULL
+                if value is None:
+                    value = sqlalchemy.sql.null()
+
+                # bind parameters before statement reaches database, so that bound parameters appear in exceptions
+                # http://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.expression.text
+                statement = statement.bindparams(sqlalchemy.bindparam(key, value=value, type_=UserDefinedType()))
+
+            # stringify bound parameters
+            # http://docs.sqlalchemy.org/en/latest/faq/sqlexpressions.html#how-do-i-render-sql-expressions-as-strings-possibly-with-bound-parameters-inlined
+            statement = str(statement.compile(compile_kwargs={"literal_binds": True}))
+
+            # execute statement
+            result = self.engine.execute(statement)
+
+            # log statement
+            self.logger.debug(statement)
+
+            # if SELECT (or INSERT with RETURNING), return result set as list of dict objects
+            if re.search(r"^\s*SELECT\s+", statement, re.I):
+                rows = result.fetchall()
+                return [dict(row) for row in rows]
+
+            # if INSERT, return primary key value for a newly inserted row
+            elif re.search(r"^\s*INSERT\s+", statement, re.I):
+                if self.engine.url.get_backend_name() == "postgresql":
+                    result = self.engine.execute(sqlalchemy.text("SELECT LASTVAL()"))
+                    return result.first()[0]
+                else:
+                    return result.lastrowid
+
+            # if DELETE or UPDATE, return number of rows matched
+            elif re.search(r"^\s*(?:DELETE|UPDATE)\s+", statement, re.I):
+                return result.rowcount
+
+            # if some other statement, return True unless exception
+            return True
+
+        # if constraint violated, return None
+        except sqlalchemy.exc.IntegrityError:
+            return None
diff --git a/test/python2.py b/tests/python2.py
similarity index 65%
rename from test/python2.py
rename to tests/python2.py
index 10f19e4..69de822 100644
--- a/test/python2.py
+++ b/tests/python2.py
@@ -1,6 +1,4 @@
 import cs50
 
-from cs50 import SQL
-
 l = cs50.get_long()
 print(l)
diff --git a/test/python3.py b/tests/python3.py
similarity index 65%
rename from test/python3.py
rename to tests/python3.py
index efef779..b545b98 100644
--- a/test/python3.py
+++ b/tests/python3.py
@@ -1,6 +1,4 @@
 import cs50
-from cs50 import SQL
 
 i = cs50.get_int()
 print(i)
-
diff --git a/tests/sqltests.py b/tests/sqltests.py
new file mode 100644
index 0000000..8e518b4
--- /dev/null
+++ b/tests/sqltests.py
@@ -0,0 +1,126 @@
+from cs50.sql import SQL
+import sys
+import unittest
+import warnings
+
+class SQLTests(unittest.TestCase):
+    def multi_inserts_enabled(self):
+        return True
+
+    def test_delete_returns_affected_rows(self):
+        rows = [
+            {"id": 1, "val": "foo"},
+            {"id": 2, "val": "bar"},
+            {"id": 3, "val": "baz"}
+        ]
+        for row in rows:
+            self.db.execute("INSERT INTO cs50(val) VALUES(:val);", val=row["val"])
+
+        print(self.db.execute("DELETE FROM cs50 WHERE id = :id", id=rows[0]["id"]))
+        print(self.db.execute("SELECT * FROM cs50"))
+        return
+
+        self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = :id", id=rows[0]["id"]), 1)
+        self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = :a or id = :b", a=rows[1]["id"], b=rows[2]["id"]), 2)
+        self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = -50"), 0)
+
+    def test_insert_returns_last_row_id(self):
+        self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('foo')"), 1)
+        self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('bar')"), 2)
+        if self.multi_inserts_enabled():
+            self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('baz'); INSERT INTO cs50(val) VALUES('qux')"), 4)
+
+    def test_select_all(self):
+        self.assertEqual(self.db.execute("SELECT * FROM cs50"), [])
+
+        rows = [
+            {"id": 1, "val": "foo"},
+            {"id": 2, "val": "bar"},
+            {"id": 3, "val": "baz"}
+        ]
+        for row in rows:
+            self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])
+
+        self.assertEqual(self.db.execute("SELECT * FROM cs50"), rows)
+
+    def test_select_cols(self):
+        rows = [
+            {"val": "foo"},
+            {"val": "bar"},
+            {"val": "baz"}
+        ]
+        for row in rows:
+            self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])
+
+        self.assertEqual(self.db.execute("SELECT val FROM cs50"), rows)
+
+    def test_select_where(self):
+        rows = [
+            {"id": 1, "val": "foo"},
+            {"id": 2, "val": "bar"},
+            {"id": 3, "val": "baz"}
+        ]
+        for row in rows:
+            self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])
+
+        self.assertEqual(self.db.execute("SELECT * FROM cs50 WHERE id = :id OR val = :val", id=rows[1]["id"], val=rows[2]["val"]), rows[1:3])
+
+    def test_update_returns_affected_rows(self):
+        rows = [
+            {"id": 1, "val": "foo"},
+            {"id": 2, "val": "bar"},
+            {"id": 3, "val": "baz"}
+        ]
+        for row in rows:
+            self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])
+
+        self.assertEqual(self.db.execute("UPDATE cs50 SET val = 'foo' WHERE id > 1"), 2)
+        self.assertEqual(self.db.execute("UPDATE cs50 SET val = 'foo' WHERE id = -50"), 0)
+
+    def tearDown(self):
+        self.db.execute("DROP TABLE cs50")
+
+    @classmethod
+    def tearDownClass(self):
+        try:
+            self.db.execute("DROP TABLE IF EXISTS cs50")
+        except Warning as e:
+            # suppress "unknown table"
+            if not str(e).startswith("(1051"):
+                raise e
+
+class MySQLTests(SQLTests):
+    @classmethod
+    def setUpClass(self):
+        self.db = SQL("mysql://root@localhost/test")
+
+    def setUp(self):
+        self.db.execute("CREATE TABLE cs50 (id INTEGER NOT NULL AUTO_INCREMENT, val VARCHAR(16), PRIMARY KEY (id))")
+
+class PostgresTests(SQLTests):
+    @classmethod
+    def setUpClass(self):
+        self.db = SQL("postgresql://postgres@localhost/test")
+
+    def setUp(self):
+        self.db.execute("CREATE TABLE cs50 (id SERIAL PRIMARY KEY, val VARCHAR(16))")
+
+class SQLiteTests(SQLTests):
+    @classmethod
+    def setUpClass(self):
+        self.db = SQL("sqlite:///test.db")
+
+    def setUp(self):
+        self.db.execute("CREATE TABLE cs50(id INTEGER PRIMARY KEY, val TEXT)")
+
+    def multi_inserts_enabled(self):
+        return False
+
+if __name__ == "__main__":
+    suite = unittest.TestSuite([
+        unittest.TestLoader().loadTestsFromTestCase(SQLiteTests),
+        unittest.TestLoader().loadTestsFromTestCase(MySQLTests),
+        unittest.TestLoader().loadTestsFromTestCase(PostgresTests)
+    ])
+
+    sys.exit(not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful())