diff --git a/.gitignore b/.gitignore
index 5e6c4a0..f1aea8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ error.log
 __pycache__
 .pytest_cache
 .cache
+.vscode
diff --git a/coaster/sqlalchemy/statemanager.py b/coaster/sqlalchemy/statemanager.py
index de2f75e..a2a5f0e 100644
--- a/coaster/sqlalchemy/statemanager.py
+++ b/coaster/sqlalchemy/statemanager.py
@@ -67,6 +67,18 @@ control state change via transitions. Sample usage::
             # A transition can do additional housekeeping
             self.datetime = datetime.utcnow()
 
+            # If AbortTransition is raised in this method, the state wont be changed.
+            # AbortTransition will return anything passed to it as return value of the method
+            # E.g.
+            # success, message = post.publish()
+
+            success = False
+            message = "A transition is not desirable right now"
+            raise AbortTransition(success, message)
+
+            # Any other exception will be raised
+            raise RandomError("This will be raised")
+
         # A transition can use a conditional state. The condition is evaluated
         # before the transition can proceed
         @state.transition(state.RECENT, state.PENDING)
@@ -204,7 +216,7 @@ from ..utils import is_collection, NameTitle
 from ..signals import coaster_signals
 from .roles import RoleMixin
 
-__all__ = ['StateManager', 'StateTransitionError',
+__all__ = ['StateManager', 'StateTransitionError', 'AbortTransition',
     'transition_error', 'transition_before', 'transition_after', 'transition_exception']
 
 
@@ -230,6 +242,21 @@ class StateTransitionError(BadRequest, TypeError):
     pass
 
 
+class AbortTransition(Exception):
+    """
+    Transitions may raise :exc:`AbortTransition` to return without changing
+    state. The parameter to this exception is returned as the transition's
+    result.
+
+    This exception is a signal to :class:`StateTransition` and will not be
+    raised to the transition's caller.
+
+    :param result: Value to return to the transition's caller
+    """
+    def __init__(self, result=None):
+        super(AbortTransition, self).__init__(result)
+
+
 # --- Classes -----------------------------------------------------------------
 
 class ManagedState(object):
@@ -458,6 +485,7 @@ class StateTransition(object):
 
     def __set_name__(self, owner, name):  # pragma: no cover
         self.name = name
+        self.data['name'] = name
 
     # Make the transition a non-data descriptor
     def __get__(self, obj, cls=None):
@@ -521,19 +549,23 @@ class StateTransitionWrapper(object):
                     label=repr(state_invalid[2])
                     ))
 
-        # Raise a transition-before signal
+        # Send a transition-before signal
         transition_before.send(self.obj, transition=self.statetransition)
-        # Call the transition function
+        # Call the transition method
         try:
             result = self.statetransition.func(self.obj, *args, **kwargs)
+        except AbortTransition as e:
+            transition_exception.send(self.obj, transition=self.statetransition, exception=e)
+            return e.args[0]
         except Exception as e:
             transition_exception.send(self.obj, transition=self.statetransition, exception=e)
             raise
+
         # Change the state for each of the state managers
         for statemanager, conditions in self.statetransition.transitions.items():
             if conditions['to'] is not None:  # Allow to=None for the @requires decorator
                 statemanager._set(self.obj, conditions['to'].value)  # Change state
-        # Raise a transition-after signal
+        # Send a transition-after signal
         transition_after.send(self.obj, transition=self.statetransition)
         return result
 
@@ -677,11 +709,12 @@ class StateManager(object):
 
     def transition(self, from_, to, if_=None, **data):
         """
-        Decorates a function to transition from one state to another. The
-        decorated function can accept any necessary parameters and perform
+        Decorates a method to transition from one state to another. The
+        decorated method can accept any necessary parameters and perform
         additional processing, or raise an exception to abort the transition.
         If it returns without an error, the state value is updated
-        automatically.
+        automatically. Transitions may also abort without raising an exception
+        using :exc:`AbortTransition`.
 
         :param from_: Required state to allow this transition (can be a state group)
         :param to: The state of the object after this transition (automatically set if no exception is raised)

StateManager needs an AbortTransition exception
If a transition does `raise AbortTransition(result)`, the state should not change, and the `result` should be returned to the caller as the return value of the transition method.

The anticipated use case is that callers will use something like `success = obj.transition(…)` or `status, message = obj.transition(…)`. All exceptions except `AbortTransition` should pass through uncaught.
diff --git a/tests/test_statemanager.py b/tests/test_statemanager.py
index ad61af4..51ab10f 100644
--- a/tests/test_statemanager.py
+++ b/tests/test_statemanager.py
@@ -9,7 +9,7 @@ from flask_sqlalchemy import SQLAlchemy
 from coaster.utils import LabeledEnum
 from coaster.auth import add_auth_attribute
 from coaster.sqlalchemy import (with_roles, BaseMixin,
-    StateManager, StateTransitionError)
+    StateManager, StateTransitionError, AbortTransition)
 from coaster.sqlalchemy.statemanager import ManagedStateWrapper
 
 
@@ -113,6 +113,17 @@ class MyPost(BaseMixin, db.Model):
     def rewind(self):
         self.datetime = datetime.utcnow() - timedelta(hours=2)
 
+    @with_roles(call={'author'})
+    @state.transition(state.UNPUBLISHED, state.PUBLISHED, message=u"Abort this transition")
+    @reviewstate.transition(reviewstate.UNLOCKED, reviewstate.PENDING, title="Publish")
+    def abort(self, success=False, empty_abort=False):
+        if not success:
+            if empty_abort:
+                raise AbortTransition()
+            else:
+                raise AbortTransition((success, 'failed'))
+        return success, 'passed'
+
     def roles_for(self, actor, anchors=()):
         roles = super(MyPost, self).roles_for(actor, anchors)
         # Cheap hack for the sake of testing, using strings instead of objects
@@ -509,6 +520,26 @@ class TestStateManager(unittest.TestCase):
         self.assertFalse(self.post.reviewstate.LOCKED)
         self.assertTrue(self.post.state.PENDING)
 
+    def test_transition_abort(self):
+        """Transitions can abort without changing state or raising an exception"""
+        self.assertTrue(self.post.state.DRAFT)
+
+        # A transition can abort returning a value (a 2-tuple here)
+        success, message = self.post.abort(success=False)
+        self.assertEqual(success, False)
+        self.assertEqual(message, "failed")
+        self.assertTrue(self.post.state.DRAFT)  # state has not changed
+
+        # A transition can abort without returning a value
+        result = self.post.abort(success=False, empty_abort=True)
+        self.assertEqual(result, None)
+        self.assertTrue(self.post.state.DRAFT)  # state has not changed
+
+        success, message = self.post.abort(success=True)
+        self.assertEqual(success, True)
+        self.assertEqual(message, 'passed')
+        self.assertTrue(self.post.state.PUBLISHED)  # state has changed
+
     def test_transition_is_available(self):
         """A transition's is_available property is reliable"""
         self.assertTrue(self.post.state.DRAFT)

pip install -e .
parse_log_pytest
requirements.txt
pytest coverage coveralls psycopg2
3.6
test_requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alembic==1.7.7
attrs==22.2.0
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@8864fb07e070b40886eecf1ed29b683ca538c16d#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
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
packaging==21.3
pluggy==1.0.0
psycopg2==2.7.7
py==1.11.0
PyExecJS==1.5.1
Pygments==2.14.0
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
shortuuid==1.0.13
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
      - attrs==22.2.0
      - 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
      - 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
      - packaging==21.3
      - pluggy==1.0.0
      - psycopg2==2.7.7
      - py==1.11.0
      - pyexecjs==1.5.1
      - pygments==2.14.0
      - 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
      - shortuuid==1.0.13
      - 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_statemanager.py::TestStateManager::test_added_regular_state_transition tests/test_statemanager.py::TestStateManager::test_added_state_group tests/test_statemanager.py::TestStateManager::test_added_state_transition tests/test_statemanager.py::TestStateManager::test_available_transitions tests/test_statemanager.py::TestStateManager::test_available_transitions_for tests/test_statemanager.py::TestStateManager::test_bestmatch_state tests/test_statemanager.py::TestStateManager::test_change_state_invalid tests/test_statemanager.py::TestStateManager::test_conditional_state tests/test_statemanager.py::TestStateManager::test_conditional_state_label tests/test_statemanager.py::TestStateManager::test_conditional_state_unmanaged_state tests/test_statemanager.py::TestStateManager::test_current_states tests/test_statemanager.py::TestStateManager::test_currently_available_transitions tests/test_statemanager.py::TestStateManager::test_duplicate_transition tests/test_statemanager.py::TestStateManager::test_group_by_state tests/test_statemanager.py::TestStateManager::test_has_nonstate tests/test_statemanager.py::TestStateManager::test_has_state tests/test_statemanager.py::TestStateManager::test_is_state tests/test_statemanager.py::TestStateManager::test_managed_state_wrapper tests/test_statemanager.py::TestStateManager::test_readonly tests/test_statemanager.py::TestStateManager::test_requires tests/test_statemanager.py::TestStateManager::test_reviewstate_also_changes tests/test_statemanager.py::TestStateManager::test_role_proxy_transitions tests/test_statemanager.py::TestStateManager::test_sql_query_added_state tests/test_statemanager.py::TestStateManager::test_sql_query_multi_value tests/test_statemanager.py::TestStateManager::test_sql_query_single_value tests/test_statemanager.py::TestStateManager::test_sql_query_state_group tests/test_statemanager.py::TestStateManager::test_state_already_exists tests/test_statemanager.py::TestStateManager::test_state_group_invalid tests/test_statemanager.py::TestStateManager::test_state_labels tests/test_statemanager.py::TestStateManager::test_transition_abort tests/test_statemanager.py::TestStateManager::test_transition_data tests/test_statemanager.py::TestStateManager::test_transition_data_name_invalid tests/test_statemanager.py::TestStateManager::test_transition_from_none tests/test_statemanager.py::TestStateManager::test_transition_invalid_from_to tests/test_statemanager.py::TestStateManager::test_transition_is_available tests/test_statemanager.py::TestStateManager::test_transition_publish_datetime tests/test_statemanager.py::TestStateManager::test_transition_publish_invalid tests/test_statemanager.py::TestStateManager::test_transition_state_lock tests/test_statemanager.py::TestStateManager::test_transition_submit
.gitignore coaster/sqlalchemy/statemanager.py
.gitignore coaster/sqlalchemy/statemanager.py