diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 090411660a2873cb98769b2b68f10f86c2935718..d0066139e34ece2e4a1dd3b143c0b9b04d6b718b 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -61,6 +61,12 @@ from .storage_common import (
     required_passes,
 )
 
+from .schema import (
+    get_schema_version,
+    get_schema_upgrades,
+    run_schema_upgrades,
+)
+
 
 class ILeaseMaintenanceObserver(Interface):
     """
@@ -88,13 +94,9 @@ class StoreOpenError(Exception):
         self.reason = reason
 
 
-class SchemaError(TypeError):
-    pass
-
-
 CONFIG_DB_NAME = u"privatestorageio-zkapauthz-v1.sqlite3"
 
-def open_and_initialize(path, required_schema_version, connect=None):
+def open_and_initialize(path, connect=None):
     """
     Open a SQLite3 database for use as a voucher store.
 
@@ -103,13 +105,6 @@ def open_and_initialize(path, required_schema_version, connect=None):
 
     :param FilePath path: The location of the SQLite3 database file.
 
-    :param int required_schema_version: The schema version which must be
-        present in the database in order for a SQLite3 connection to be
-        returned.
-
-    :raise SchemaError: If the schema in the database does not match the
-        required schema version.
-
     :return: A SQLite3 connection object for the database at the given path.
     """
     if connect is None:
@@ -135,86 +130,9 @@ def open_and_initialize(path, required_schema_version, connect=None):
 
     with conn:
         cursor = conn.cursor()
-        cursor.execute(
-            # This code knows how to create schema version 1.  This is
-            # regardless of what the caller *wants* to find in the database.
-            """
-            CREATE TABLE IF NOT EXISTS [version] AS SELECT 1 AS [version]
-            """
-        )
-        cursor.execute(
-            """
-            SELECT [version] FROM [version]
-            """
-        )
-        [(actual_version,)] = cursor.fetchall()
-        if actual_version != required_schema_version:
-            raise SchemaError(
-                "Unexpected database schema version.  Required {}.  Got {}.".format(
-                    required_schema_version,
-                    actual_version,
-                ),
-            )
-
-        cursor.execute(
-            # A denormalized schema because, for now, it's simpler. :/
-            """
-            CREATE TABLE IF NOT EXISTS [vouchers] (
-                [number] text,
-                [created] text,                     -- An ISO8601 date+time string.
-                [state] text DEFAULT "pending",     -- pending, double-spend, redeemed
-
-                [finished] text DEFAULT NULL,       -- ISO8601 date+time string when
-                                                    -- the current terminal state was entered.
-
-                [token-count] num DEFAULT NULL,     -- Set in the redeemed state to the number
-                                                    -- of tokens received on this voucher's
-                                                    -- redemption.
-
-                PRIMARY KEY([number])
-            )
-            """,
-        )
-        cursor.execute(
-            """
-            CREATE TABLE IF NOT EXISTS [tokens] (
-                [text] text, -- The random string that defines the token.
-                [voucher] text, -- Reference to the voucher these tokens go with.
-
-                PRIMARY KEY([text])
-                FOREIGN KEY([voucher]) REFERENCES [vouchers]([number])
-            )
-            """,
-        )
-        cursor.execute(
-            """
-            CREATE TABLE IF NOT EXISTS [unblinded-tokens] (
-                [token] text, -- The base64 encoded unblinded token.
-
-                PRIMARY KEY([token])
-            )
-            """,
-        )
-        cursor.execute(
-            """
-            CREATE TABLE IF NOT EXISTS [lease-maintenance-spending] (
-                [id] integer, -- A unique identifier for a group of activity.
-                [started] text, -- ISO8601 date+time string when the activity began.
-                [finished] text, -- ISO8601 date+time string when the activity completed (or null).
-
-                -- The number of passes that would be required to renew all
-                -- shares encountered during this activity.  Note that because
-                -- leases on different shares don't necessarily expire at the
-                -- same time this is not necessarily the number of passes
-                -- **actually** used during this activity.  Some shares may
-                -- not have required lease renewal.  Also note that while the
-                -- activity is ongoing this value may change.
-                [count] integer,
-
-                PRIMARY KEY([id])
-            )
-            """,
-        )
+        actual_version = get_schema_version(cursor)
+        schema_upgrades = list(get_schema_upgrades(actual_version))
+        run_schema_upgrades(schema_upgrades, cursor)
     return conn
 
 
@@ -280,7 +198,6 @@ class VoucherStore(object):
         db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME))
         conn = open_and_initialize(
             db_path,
-            required_schema_version=1,
             connect=connect,
         )
         return cls(
diff --git a/src/_zkapauthorizer/schema.py b/src/_zkapauthorizer/schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..cf71a9c2a9d11b26ba4756e505284770a6952dd3
--- /dev/null
+++ b/src/_zkapauthorizer/schema.py
@@ -0,0 +1,134 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import (
+    unicode_literals,
+)
+
+"""
+This module defines the database schema used by the model interface.
+"""
+
+def get_schema_version(cursor):
+    cursor.execute(
+        """
+        CREATE TABLE IF NOT EXISTS [version] AS SELECT 0 AS [version]
+        """
+    )
+    cursor.execute(
+        """
+        SELECT [version] FROM [version]
+        """
+    )
+    [(actual_version,)] = cursor.fetchall()
+    return actual_version
+
+
+def get_schema_upgrades(from_version):
+    """
+    Generate unicode strings containing SQL expressions to alter a schema from
+    ``from_version`` to the latest version.
+
+    :param int from_version: The version of the schema which may require
+        upgrade.
+    """
+    while from_version in _UPGRADES:
+        for upgrade in _UPGRADES[from_version]:
+            yield upgrade
+        yield _INCREMENT_VERSION
+        from_version += 1
+
+
+def run_schema_upgrades(upgrades, cursor):
+    """
+    Apply the given upgrades using the given cursor.
+
+    :param list[unicode] upgrades: The SQL statements to apply for the
+        upgrade.
+
+    :param cursor: A DB-API cursor to use to run the SQL.
+    """
+    for upgrade in upgrades:
+        cursor.execute(upgrade)
+
+
+_INCREMENT_VERSION = (
+    """
+    UPDATE [version]
+    SET [version] = [version] + 1
+    """
+)
+
+# A mapping from old schema versions to lists of unicode strings of SQL to
+# execute against that version of the schema to create the successor schema.
+_UPGRADES = {
+    0: [
+        """
+        CREATE TABLE [vouchers] (
+            [number] text,
+            [created] text,                     -- An ISO8601 date+time string.
+            [state] text DEFAULT "pending",     -- pending, double-spend, redeemed
+
+            [finished] text DEFAULT NULL,       -- ISO8601 date+time string when
+                                                -- the current terminal state was entered.
+
+            [token-count] num DEFAULT NULL,     -- Set in the redeemed state to the number
+                                                -- of tokens received on this voucher's
+                                                -- redemption.
+
+            PRIMARY KEY([number])
+        )
+        """,
+        """
+        CREATE TABLE [tokens] (
+            [text] text, -- The random string that defines the token.
+            [voucher] text, -- Reference to the voucher these tokens go with.
+
+            PRIMARY KEY([text])
+            FOREIGN KEY([voucher]) REFERENCES [vouchers]([number])
+        )
+        """,
+        """
+        CREATE TABLE [unblinded-tokens] (
+            [token] text, -- The base64 encoded unblinded token.
+
+            PRIMARY KEY([token])
+        )
+        """,
+        """
+        CREATE TABLE [lease-maintenance-spending] (
+            [id] integer, -- A unique identifier for a group of activity.
+            [started] text, -- ISO8601 date+time string when the activity began.
+            [finished] text, -- ISO8601 date+time string when the activity completed (or null).
+
+            -- The number of passes that would be required to renew all
+            -- shares encountered during this activity.  Note that because
+            -- leases on different shares don't necessarily expire at the
+            -- same time this is not necessarily the number of passes
+            -- **actually** used during this activity.  Some shares may
+            -- not have required lease renewal.  Also note that while the
+            -- activity is ongoing this value may change.
+            [count] integer,
+
+            PRIMARY KEY([id])
+        )
+        """,
+    ],
+}
+
+def _check_consistency():
+    if _UPGRADES.keys() != range(len(_UPGRADES)):
+        raise TypeError("Inconsistent schema versions in schema upgraders.")
+
+_check_consistency()
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 9ea41591dfadd3eac428726d22856fead977f00b..bf2dbaf7e3c4849ef8c8159450cb01ee7dca45ad 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -68,16 +68,12 @@ from hypothesis.strategies import (
 from twisted.python.runtime import (
     platform,
 )
-from twisted.python.filepath import (
-    FilePath,
-)
 
 from ..storage_common import (
     BYTES_PER_PASS,
 )
 
 from ..model import (
-    SchemaError,
     StoreOpenError,
     VoucherStore,
     Voucher,
@@ -85,7 +81,6 @@ from ..model import (
     DoubleSpend,
     Redeemed,
     LeaseMaintenanceActivity,
-    open_and_initialize,
     memory_connect,
 )
 
@@ -107,22 +102,6 @@ class VoucherStoreTests(TestCase):
     """
     Tests for ``VoucherStore``.
     """
-    def test_create_mismatched_schema(self):
-        """
-        ``open_and_initialize`` raises ``SchemaError`` if asked for a database
-        with a schema version other than it can create.
-        """
-        tempdir = self.useFixture(TempDir())
-        dbpath = tempdir.join(b"db.sqlite3")
-        self.assertThat(
-            lambda: open_and_initialize(
-                FilePath(dbpath),
-                required_schema_version=100,
-            ),
-            raises(SchemaError),
-        )
-
-
     @given(tahoe_configs(), datetimes(), vouchers())
     def test_get_missing(self, get_config, now, voucher):
         """