diff --git a/deps.edn b/deps.edn
index 689860c9e437f9d33f023017d70e2074c9337a99..f85e9d825344bfff4de398e1180930220769da05 100644
--- a/deps.edn
+++ b/deps.edn
@@ -665,9 +665,17 @@
    :extra-paths ["dev/src"]
    :main-opts   ["-m" "dev.liquibase"]}
 
-  ;; the following aliases are convenience for running tests against certain parts of the codebase.
-  ;;
-  ;;   clj -X:dev:ee:ee-dev:test:test/mbql
+  ;; Migrate CLI:
+  ;;    clojure -M:migrate <command>
+  ;; E.g.
+  ;;    clojure -M:migrate up                       ;; migrate up to the latest
+  ;;    clojure -M:migrate rollback count 2         ;; rollback 2 migrations
+  ;;    clojure -M:migrate rollback id "v40.00.001" ;; rollback to a specific migration with id
+  ;;    clojure -M:migrate status                   ;; print the latest migration id
+  :migrate
+  {:extra-deps {io.github.camsaul/humane-are {:mvn/version "1.0.2"}}
+   :extra-paths ["dev/src"]
+   :main-opts   ["-m" "dev.migrate"]}
 
   ;; run tests against MLv2. Very fast since this is almost all ^:parallel
   :test/mlv2
diff --git a/dev/src/dev.clj b/dev/src/dev.clj
index fa9abd8302f01877afb920b8e5488347afd4fdbd..494a8e20b07562905b89d92e6228798b90180961 100644
--- a/dev/src/dev.clj
+++ b/dev/src/dev.clj
@@ -55,6 +55,7 @@
    [clojure.test]
    [dev.debug-qp :as debug-qp]
    [dev.explain :as dev.explain]
+   [dev.migrate :as dev.migrate]
    [dev.model-tracking :as model-tracking]
    [hashp.core :as hashp]
    [honey.sql :as sql]
@@ -101,6 +102,9 @@
   pprint-sql]
  [dev.explain
   explain-query]
+ [dev.migrate
+  migrate!
+  rollback!]
  [model-tracking
   track!
   untrack!
@@ -258,14 +262,6 @@
         (a/>!! canceled-chan :cancel)
         (throw e)))))
 
-(defn migrate!
-  "Run migrations for the Metabase application database. Possible directions are `:up` (default), `:force`, `:down`, and
-  `:release-locks`. When migrating `:down` pass along a version to migrate to (44+)."
-  ([]
-   (migrate! :up))
-  ([direction & [version]]
-   (mdb/migrate! (mdb/data-source) direction version)))
-
 (methodical/defmethod t2.connection/do-with-connection :model/Database
   "Support running arbitrary queries against data warehouse DBs for easy REPL debugging. Only works for SQL+JDBC drivers
   right now!
diff --git a/dev/src/dev/migrate.clj b/dev/src/dev/migrate.clj
new file mode 100644
index 0000000000000000000000000000000000000000..3e773a2d43aa56e0178298cc458204a0f211b842
--- /dev/null
+++ b/dev/src/dev/migrate.clj
@@ -0,0 +1,104 @@
+(ns dev.migrate
+  (:gen-class)
+  (:require
+   [metabase.db :as mdb]
+   [metabase.db.liquibase :as liquibase]
+   [metabase.util.malli :as mu]
+   [toucan2.core :as t2])
+  (:import
+   (liquibase Liquibase)))
+
+(set! *warn-on-reflection* true)
+
+(defn- latest-migration
+  []
+  ((juxt :id :comments)
+   (t2/query-one {:select [:id :comments]
+                  :from   [:databasechangelog]
+                  :order-by [[:orderexecuted :desc]]
+                  :limit 1})))
+(defn migrate!
+  "Run migrations for the Metabase application database. Possible directions are `:up` (default), `:force`, `:down`, and
+  `:release-locks`. When migrating `:down` pass along a version to migrate to (44+)."
+  ([]
+   (migrate! :up))
+  ;; do we really use this in dev?
+  ([direction & [version]]
+   (mdb/migrate! (mdb/db-type) (mdb/data-source)
+                 direction version)
+   #_{:clj-kondo/ignore [:discouraged-var]}
+   (println "Migrated up. Latest migration:" (latest-migration))))
+
+(defn- rollback-n-migrations!
+  [^Integer n]
+  (with-open [conn (.getConnection (mdb/data-source))]
+    (liquibase/with-liquibase [^Liquibase liquibase conn]
+      (liquibase/with-scope-locked liquibase
+        (.rollback liquibase n "")))))
+
+(defn- migration-since
+  [id]
+  (->> (t2/query-one {:select [[:%count.* :count]]
+                      :from   [:databasechangelog]
+                      :where  [:> :orderexecuted {:select   [:orderexecuted]
+                                                  :from     [:databasechangelog]
+                                                  :where    [:like :id (format "%s%%" id)]
+                                                  :order-by [:orderexecuted :desc]
+                                                  :limit    1}]
+                      :limit 1})
+       :count
+       ;; includes the selected id
+       inc))
+
+(defn- maybe-parse-long
+  [x]
+  (cond-> x
+    (string? x)
+    parse-long))
+
+(mu/defn rollback!
+  "Rollback helper, can take a number of migrations to rollback or a specific migration ID(inclusive).
+
+    ;; Rollback 2 migrations:
+    (rollback! :count 2)
+
+    ;; rollback to \"v50.2024-03-18T16:00:00\" (inclusive)
+    (rollback! :id \"v50.2024-03-18T16:00:00\")"
+ [k :- [:enum :id :count "id" "count"]
+  target]
+ (let [n (case (keyword k)
+           :id    (migration-since target)
+           :count (maybe-parse-long target))]
+  (rollback-n-migrations! n)
+  #_{:clj-kondo/ignore [:discouraged-var]}
+  (println (format "Rollbacked %d migrations. Latest migration: %s" n (latest-migration)))))
+
+(defn migration-status
+  "Print the latest migration ID."
+  []
+  #_{:clj-kondo/ignore [:discouraged-var]}
+  (println "Current migration:" (latest-migration)))
+
+(defn -main
+  "Migrations helpers
+
+  Usage:
+    clojure -M:migrate up                         ;; migrate up to the latest
+    clojure -M:migrate rollback count 2           ;; rollback 2 migrations
+    clojure -M:migrate rollback id \"v40.00.001\" ;; rollback to a specific migration with id
+    clojure -M:migrate status                     ;; print the latest migration id"
+
+  [& args]
+  (let [[cmd & migration-args] args]
+    (case cmd
+      "rollback"
+      (apply rollback! migration-args)
+
+      "up"
+      (apply migrate! migration-args)
+
+      "status"
+      (migration-status)
+
+      (throw (ex-info "Invalid command" {:command cmd
+                                         :args    args})))))
diff --git a/dev/src/user.clj b/dev/src/user.clj
index a2378e5f55fb8c0dee1a50b13d5fa73143d9cd1e..6f30abc0c058418da463d525b1b847c5608b6aaa 100644
--- a/dev/src/user.clj
+++ b/dev/src/user.clj
@@ -1,25 +1,33 @@
 (ns user
   (:require
    [environ.core :as env]
-   [humane-are.core :as humane-are]
-   [mb.hawk.assert-exprs]
    [metabase.bootstrap]
-   [metabase.test-runner.assert-exprs]
-   [pjstadig.humane-test-output :as humane-test-output]))
+   [metabase.plugins.classloader :as classloader]
+   [metabase.util :as u]))
 
-;; Initialize Humane Test Output if it's not already initialized. Don't enable humane-test-output when running tests
-;; from the CLI, it breaks diffs. This uses [[env/env]] rather than [[metabase.config]] so we don't load that namespace
-;; before we load [[metabase.bootstrap]]
-(when-not (= (env/env :mb-run-mode) "test")
-  (humane-test-output/activate!))
+;; Wrap these with ignore-exceptions to reduce the "required" deps of this namespace
+;; We sometimes need to run cmd stuffs like `clojure -M:migrate rollback n 3` and these
+;; libraries might not be available in the classpath
+(u/ignore-exceptions
+ ;; make sure stuff like `=?` and what not are loaded
+ (classloader/require 'mb.hawk.assert-exprs))
 
-;;; Same for https://github.com/camsaul/humane-are
-(humane-are/install!)
+(u/ignore-exceptions
+ (classloader/require 'metabase.test-runner.assert-exprs))
 
-(comment metabase.bootstrap/keep-me
-         ;; make sure stuff like `=?` and what not are loaded
-         mb.hawk.assert-exprs/keep-me
-         metabase.test-runner.assert-exprs/keep-me)
+(u/ignore-exceptions
+ (classloader/require 'humane-are.core)
+ ((resolve 'humane-are.core/install!)))
+
+(u/ignore-exceptions
+ (classloader/require 'pjstadig.humane-test-output)
+ ;; Initialize Humane Test Output if it's not already initialized. Don't enable humane-test-output when running tests
+ ;; from the CLI, it breaks diffs. This uses [[env/env]] rather than [[metabase.config]] so we don't load that namespace
+ ;; before we load [[metabase.bootstrap]]
+ (when-not (= (env/env :mb-run-mode) "test")
+   ((resolve 'pjstadig.humane-test-output/activate!))))
+
+(comment metabase.bootstrap/keep-me)
 
 (defn dev
   "Load and switch to the 'dev' namespace."