diff --git a/CHANGES.rst b/CHANGES.rst
index 28341ca..ba6ca16 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -45,6 +45,7 @@
 * Added datasets for limited enumeration in role access, as a stopgap until
   migration to GraphQL
 * Removed ShortUUID in favour of the more stable Base58
+* MarkdownColumn now supports a custom markdown processor and options
 
 0.6.0
 -----
diff --git a/coaster/sqlalchemy/__init__.py b/coaster/sqlalchemy/__init__.py
index 40d3079..9b6a43f 100644
--- a/coaster/sqlalchemy/__init__.py
+++ b/coaster/sqlalchemy/__init__.py
@@ -18,6 +18,7 @@ from .columns import *  # NOQA
 from .comparators import *  # NOQA
 from .functions import *  # NOQA
 from .immutable_annotation import *  # NOQA
+from .markdown import *  # NOQA
 from .mixins import *  # NOQA
 from .registry import *  # NOQA
 from .roles import *  # NOQA
diff --git a/coaster/sqlalchemy/columns.py b/coaster/sqlalchemy/columns.py
index 570aa0a..0873fa1 100644
--- a/coaster/sqlalchemy/columns.py
+++ b/coaster/sqlalchemy/columns.py
@@ -8,28 +8,16 @@ SQLAlchemy column types
 from __future__ import absolute_import
 import six
 
-from sqlalchemy import Column, UnicodeText
-from sqlalchemy.ext.mutable import Mutable, MutableComposite
-from sqlalchemy.orm import composite
+from sqlalchemy import UnicodeText
+from sqlalchemy.ext.mutable import Mutable
 from sqlalchemy.types import TEXT, TypeDecorator, UserDefinedType
 from sqlalchemy_utils.types import URLType as UrlTypeBase
 from sqlalchemy_utils.types import UUIDType
 
-from flask import Markup
-
 from furl import furl
 import simplejson
 
-from ..utils import markdown
-
-__all__ = [
-    'JsonDict',
-    'MarkdownComposite',
-    'MarkdownColumn',
-    'UUIDType',
-    'UrlType',
-    'markdown_column',
-]
+__all__ = ['JsonDict', 'UUIDType', 'UrlType']
 
 
 class JsonType(UserDefinedType):
@@ -124,93 +112,6 @@ class MutableDict(Mutable, dict):
 MutableDict.associate_with(JsonDict)
 
 
-@six.python_2_unicode_compatible
-class MarkdownComposite(MutableComposite):
-    """
-    Represents GitHub-flavoured Markdown text and rendered HTML as a composite column.
-    """
-
-    def __init__(self, text, html=None):
-        if html is None:
-            self.text = text  # This will regenerate HTML
-        else:
-            object.__setattr__(self, 'text', text)
-            object.__setattr__(self, '_html', html)
-
-    # If the text value is set, regenerate HTML, then notify parents of the change
-    def __setattr__(self, key, value):
-        if key == 'text':
-            object.__setattr__(self, '_html', markdown(value))
-        object.__setattr__(self, key, value)
-        self.changed()
-
-    # Return column values for SQLAlchemy to insert into the database
-    def __composite_values__(self):
-        return (self.text, self._html)
-
-    # Return a string representation of the text (see class decorator)
-    def __str__(self):
-        return six.text_type(self.text)
-
-    # Return a HTML representation of the text
-    def __html__(self):
-        return self._html or u''
-
-    # Return a Markup string of the HTML
-    @property
-    def html(self):
-        return Markup(self._html or u'')
-
-    # Compare text value
-    def __eq__(self, other):
-        return (
-            (self.text == other.text)
-            if isinstance(other, MarkdownComposite)
-            else (self.text == other)
-        )
-
-    def __ne__(self, other):
-        return not self.__eq__(other)
-
-    # Return state for pickling
-    def __getstate__(self):
-        return (self.text, self._html)
-
-    # Set state from pickle
-    def __setstate__(self, state):
-        object.__setattr__(self, 'text', state[0])
-        object.__setattr__(self, '_html', state[1])
-        self.changed()
-
-    def __bool__(self):
-        return bool(self.text)
-
-    __nonzero__ = __bool__
-
-    # Allow a composite column to be assigned a string value
-    @classmethod  # NOQA: A003
-    def coerce(cls, key, value):  # NOQA: A003
-        return cls(value)
-
-
-def markdown_column(name, deferred=False, group=None, **kwargs):
-    """
-    Create a composite column that autogenerates HTML from Markdown text,
-    storing data in db columns named with ``_html`` and ``_text`` prefixes.
-    """
-    return composite(
-        MarkdownComposite,
-        Column(name + '_text', UnicodeText, **kwargs),
-        Column(name + '_html', UnicodeText, **kwargs),
-        deferred=deferred,
-        group=group or name,
-    )
-
-
-# Compatibility name
-MarkdownColumn = markdown_column
-
-
 class UrlType(UrlTypeBase):
     """
     Extension of URLType_ from SQLAlchemy-Utils that adds basic validation to
diff --git a/coaster/sqlalchemy/markdown.py b/coaster/sqlalchemy/markdown.py
new file mode 100644
index 0000000..7f089ff
--- /dev/null
+++ b/coaster/sqlalchemy/markdown.py
@@ -0,0 +1,134 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, unicode_literals
+import six
+
+from sqlalchemy import Column, UnicodeText
+from sqlalchemy.ext.mutable import MutableComposite
+from sqlalchemy.orm import composite
+
+from flask import Markup
+
+from ..utils import markdown as markdown_processor
+
+__all__ = ['MarkdownComposite', 'MarkdownColumn', 'markdown_column']
+
+
+@six.python_2_unicode_compatible
+class MarkdownComposite(MutableComposite):
+    """
+    Represents GitHub-flavoured Markdown text and rendered HTML as a composite column.
+    """
+
+    #: Markdown processor. Subclasses can override this. This has to be a staticmethod
+    #: or the markdown processor will receive `self` as first parameter
+    markdown = staticmethod(markdown_processor)
+    #: Markdown options. Subclasses can override this
+    options = {}
+
+    def __init__(self, text, html=None):
+        if html is None:
+            self.text = text  # This will regenerate HTML
+        else:
+            self._text = text
+            self._html = html
+
+    # Return column values for SQLAlchemy to insert into the database
+    def __composite_values__(self):
+        return (self._text, self._html)
+
+    # Return a string representation of the text (see class decorator)
+    def __str__(self):
+        return six.text_type(self.text)
+
+    # Return a HTML representation of the text
+    def __html__(self):
+        return self._html or ''
+
+    # Return a Markup string of the HTML
+    @property
+    def html(self):
+        return Markup(self._html or '')
+
+    @property
+    def text(self):
+        return self._text
+
+    @text.setter
+    def text(self, value):
+        self._text = value
+        self._html = self.markdown(
+            value, **(self.options() if callable(self.options) else self.options)
+        )
+        self.changed()
+
+    # Compare text value
+    def __eq__(self, other):
+        return isinstance(other, MarkdownComposite) and (
+            self.__composite_values__() == other.__composite_values__()
+        )
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    # Pickle support methods implemented as per SQLAlchemy documentation, but not
+    # tested here as we don't use them.
+    # https://docs.sqlalchemy.org/en/13/orm/extensions/mutable.html#id1
+
+    def __getstate__(self):  # pragma: no cover
+        # Return state for pickling
+        return (self._text, self._html)
+
+    def __setstate__(self, state):  # pragma: no cover
+        # Set state from pickle
+        self._text, self._html = state
+        self.changed()
+
+    def __bool__(self):
+        return bool(self._text)
+
+    __nonzero__ = __bool__
+
+    # Allow a composite column to be assigned a string value
+    @classmethod  # NOQA: A003
+    def coerce(cls, key, value):  # NOQA: A003
+        return cls(value)
+
+
+def markdown_column(
+    name, deferred=False, group=None, markdown=None, options=None, **kwargs
+):
+    """
+    Create a composite column that autogenerates HTML from Markdown text,
+    storing data in db columns named with ``_html`` and ``_text`` prefixes.
+
+    :param str name: Column name base
+    :param bool deferred: Whether the columns should be deferred by default
+    :param str group: Defer column group
+    :param markdown: Markdown processor function (default: Coaster's implementation)
+    :param options: Additional options for the Markdown processor
+    :param kwargs: Additional column options, passed to SQLAlchemy's column constructor
+    """
+
+    # Construct a custom subclass of MarkdownComposite and set the markdown processor
+    # and processor options on it. We'll pass this class to SQLAlchemy's composite
+    # constructor.
+    class CustomMarkdownComposite(MarkdownComposite):
+        pass
+
+    CustomMarkdownComposite.options = options if options is not None else {}
+    if markdown is not None:
+        CustomMarkdownComposite.markdown = staticmethod(markdown)
+
+    return composite(
+        CustomMarkdownComposite
+        if (markdown is not None or options is not None)
+        else MarkdownComposite,
+        Column(name + '_text', UnicodeText, **kwargs),
+        Column(name + '_html', UnicodeText, **kwargs),
+        deferred=deferred,
+        group=group or name,
+    )
+
+
+# Compatibility name
+MarkdownColumn = markdown_column

MarkdownColumn should accept markdown configuration
A markdown column may specifically need to allow sanitized HTML, or be pure Markdown, or something else of a variety of configuration options allowed by the Markdown processor.

1. Class `MarkdownComposite` accepts an `options={}` parameter as the last item
2. Class `MarkdownColumn` also accepts `options={}` (position irrelevant, but last for consistency)
3. `MarkdownColumn` calls `composite` with `functools.partial(MarkdownComposite, options=options)`
4. `MarkdownComposite.__setattr__` calls `markdown(value, **self.options)` (pending change in Markdown processor in #182)
diff --git a/tests/test_sqlalchemy_markdowncolumn.py b/tests/test_sqlalchemy_markdowncolumn.py
index 88b871a..c5ac7e5 100644
--- a/tests/test_sqlalchemy_markdowncolumn.py
+++ b/tests/test_sqlalchemy_markdowncolumn.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 
-from __future__ import absolute_import
+from __future__ import absolute_import, unicode_literals
 
 import unittest
 
@@ -16,6 +16,20 @@ class MarkdownData(BaseMixin, db.Model):
     value = MarkdownColumn('value', nullable=False)
 
 
+class MarkdownHtmlData(BaseMixin, db.Model):
+    __tablename__ = 'md_html_data'
+    value = MarkdownColumn('value', nullable=False, options={'html': True})
+
+
+def fake_markdown(text):
+    return 'fake-markdown'
+
+
+class FakeMarkdownData(BaseMixin, db.Model):
+    __tablename__ = 'fake_md_data'
+    value = MarkdownColumn('value', nullable=False, markdown=fake_markdown)
+
+
 # -- Tests --------------------------------------------------------------------
 
 
@@ -34,7 +48,7 @@ class TestMarkdownColumn(unittest.TestCase):
         self.ctx.pop()
 
     def test_markdown_column(self):
-        text = u"""# this is going to be h1.\n- Now a list. \n- 1\n- 2\n- 3"""
+        text = """# this is going to be h1.\n- Now a list. \n- 1\n- 2\n- 3"""
         data = MarkdownData(value=text)
         self.session.add(data)
         self.session.commit()
@@ -44,9 +58,9 @@ class TestMarkdownColumn(unittest.TestCase):
         assert data.value.__html__() == markdown(text)
 
     def test_does_not_render_on_load(self):
-        text = u"This is the text"
+        text = "This is the text"
         real_html = markdown(text)
-        fake_html = u"This is not the text"
+        fake_html = "This is not the text"
         data = MarkdownData(value=text)
         self.session.add(data)
 
@@ -75,12 +89,35 @@ class TestMarkdownColumn(unittest.TestCase):
         assert data.value.__html__() == real_html
 
     def test_raw_value(self):
-        text = u"This is the text"
+        text = "This is the text"
         data = MarkdownData()
         self.session.add(data)
         data.value = text
         self.session.commit()
 
+    def test_empty_value(self):
+        doc = MarkdownData(value=None)
+        assert not doc.value
+        assert doc.value.text is None
+        assert doc.value.html == ''
+
+    def test_html_customization(self):
+        """Markdown columns may specify custom Markdown processor options."""
+        text = "Allow <b>some</b> HTML"
+        d1 = MarkdownData(value=text)
+        d2 = MarkdownHtmlData(value=text)
+
+        assert d1.value.text == d2.value.text
+        assert d1.value != d2.value
+        assert d1.value.html == '<p>Allow &lt;b&gt;some&lt;/b&gt; HTML</p>'
+        assert d2.value.html == '<p>Allow <b>some</b> HTML</p>'
+
+    def test_custom_markdown_processor(self):
+        """Markdown columns may specify their own markdown processor."""
+        doc = FakeMarkdownData(value="This is some text")
+        assert doc.value.text == "This is some text"
+        assert doc.value.html == 'fake-markdown'
+
 
 class TestMarkdownColumn2(TestMarkdownColumn):
     app = app2

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y gcc
3.6
requirements.txt test_requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alembic==1.7.7
aniso8601==10.0.0
attrs==22.2.0
base58==2.1.1
bcrypt==4.0.1
bleach==4.1.0
blinker==1.5
certifi==2021.5.30
charset-normalizer==2.0.12
click==8.0.4
-e git+https://github.com/hasgeek/coaster.git@fecdc2eef967b4a02e76b24001ef133c0e7be9d9#egg=coaster
coverage==6.2
coveralls==3.3.1
dataclasses==0.8
docflow==0.3.3
docopt==0.6.2
filelock==3.4.1
Flask==2.0.3
Flask-Assets==2.1.0
Flask-Migrate==4.0.1
Flask-Script==2.0.6
Flask-SQLAlchemy==2.5.1
furl==2.1.4
greenlet==2.0.2
html2text==2020.1.16
html5lib==1.1
idna==3.10
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig==1.1.1
isoweek==1.3.3
itsdangerous==2.0.1
Jinja2==3.0.3
joblib==1.1.1
Mako==1.1.6
Markdown==3.3.7
MarkupSafe==2.0.1
nltk==3.6.7
orderedmultidict==1.0.1
packaging==21.3
pluggy==1.0.0
psycopg2==2.7.7
py==1.11.0
PyExecJS==1.5.1
Pygments==2.14.0
pymdown-extensions==9.1
pyparsing==3.1.4
pytest==7.0.1
pytz==2025.2
regex==2023.8.8
requests==2.27.1
requests-file==2.1.0
semantic-version==2.10.0
simplejson==3.20.1
six==1.17.0
SQLAlchemy==1.4.54
SQLAlchemy-Utils==0.41.1
tldextract==3.1.2
tomli==1.2.3
tqdm==4.64.1
typing_extensions==4.1.1
UgliPyJS==0.2.5
Unidecode==1.3.8
urllib3==1.26.20
webassets==2.0
webencodings==0.5.1
Werkzeug==2.0.3
zipp==3.6.0

name: coaster
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alembic==1.7.7
      - aniso8601==10.0.0
      - attrs==22.2.0
      - base58==2.1.1
      - bcrypt==4.0.1
      - bleach==4.1.0
      - blinker==1.5
      - charset-normalizer==2.0.12
      - click==8.0.4
      - coverage==6.2
      - coveralls==3.3.1
      - dataclasses==0.8
      - docflow==0.3.3
      - docopt==0.6.2
      - filelock==3.4.1
      - flask==2.0.3
      - flask-assets==2.1.0
      - flask-migrate==4.0.1
      - flask-script==2.0.6
      - flask-sqlalchemy==2.5.1
      - furl==2.1.4
      - greenlet==2.0.2
      - html2text==2020.1.16
      - html5lib==1.1
      - idna==3.10
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - iniconfig==1.1.1
      - isoweek==1.3.3
      - itsdangerous==2.0.1
      - jinja2==3.0.3
      - joblib==1.1.1
      - mako==1.1.6
      - markdown==3.3.7
      - markupsafe==2.0.1
      - nltk==3.6.7
      - orderedmultidict==1.0.1
      - packaging==21.3
      - pluggy==1.0.0
      - psycopg2==2.7.7
      - py==1.11.0
      - pyexecjs==1.5.1
      - pygments==2.14.0
      - pymdown-extensions==9.1
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytz==2025.2
      - regex==2023.8.8
      - requests==2.27.1
      - requests-file==2.1.0
      - semantic-version==2.10.0
      - simplejson==3.20.1
      - six==1.17.0
      - sqlalchemy==1.4.54
      - sqlalchemy-utils==0.41.1
      - tldextract==3.1.2
      - tomli==1.2.3
      - tqdm==4.64.1
      - typing-extensions==4.1.1
      - uglipyjs==0.2.5
      - unidecode==1.3.8
      - urllib3==1.26.20
      - webassets==2.0
      - webencodings==0.5.1
      - werkzeug==2.0.3
      - zipp==3.6.0
prefix: /opt/conda/envs/coaster

tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn::test_custom_markdown_processor tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn::test_does_not_render_on_load tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn::test_empty_value tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn::test_html_customization tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn::test_markdown_column tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn::test_raw_value
tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn2::test_custom_markdown_processor tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn2::test_does_not_render_on_load tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn2::test_empty_value tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn2::test_html_customization tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn2::test_markdown_column tests/test_sqlalchemy_markdowncolumn.py::TestMarkdownColumn2::test_raw_value
coaster/sqlalchemy/markdown.py coaster/sqlalchemy/columns.py coaster/sqlalchemy/__init__.py CHANGES.rst
coaster/sqlalchemy/markdown.py coaster/sqlalchemy/columns.py coaster/sqlalchemy/__init__.py CHANGES.rst