diff --git a/shared/src/metabase/shared/util/i18n.cljs b/shared/src/metabase/shared/util/i18n.cljs
index 7d45853075c65012d2ab011aad58ac158efbac43..6a1dafeef28f506b07b6a8a03651e7fe34a18fcb 100644
--- a/shared/src/metabase/shared/util/i18n.cljs
+++ b/shared/src/metabase/shared/util/i18n.cljs
@@ -8,19 +8,39 @@
 (comment metabase.shared.util.i18n/keep-me
          ttag/keep-me)
 
+(defn- escape-format-string
+  "Converts `''` to `'` inside the string; that's `java.text.MessageFormat` escaping that isn't needed in JS."
+  [format-string]
+  (str/replace format-string #"''" "'"))
+
 (defn js-i18n
-  "Format an i18n `format-string` with `args` with a translated string in the user locale."
+  "Format an i18n `format-string` with `args` with a translated string in the user locale.
+
+  The strings are formatted in `java.test.MessageFormat` style. That's used directly in JVM Clojure, but in CLJS we have
+  to adapt to ttag, which doesn't have the same escaping rules.
+  - 'xyz' single quotes wrap literal text which should not be interpolated, and could contain literal '{0}'.
+  - A literal single quote is written with two single quotes: `''`
+  The first part is not supported at all. `''` is converted to a single `'`."
   [format-string & args]
-  (let [strings (str/split format-string #"\{\d+\}")]
+  (let [strings (-> format-string
+                    escape-format-string
+                    (str/split #"\{\d+\}"))]
     (apply ttag/t (clj->js strings) (clj->js args))))
 
+(def ^:private re-param-zero #"\{0\}")
+
 (defn js-i18n-n
-  "Format an i18n `format-string` with the appropritae plural form based on the value `n`.
+  "Format an i18n `format-string` with the appropriate plural form based on the value `n`.
    Allows `n` to be interpolated into the string using {0}."
   [format-string format-string-pl n]
-  (let [strings (str/split format-string #"\{0\}")
-        strings (if (= (count strings) 1) [format-string ""] strings)
-        has-n?  (re-find #".*\{0\}.*" format-string)]
+  (let [format-string-esc (escape-format-string format-string)
+        strings           (str/split format-string-esc re-param-zero)
+        strings           (if (= (count strings) 1)
+                            [format-string-esc ""]
+                            strings)
+        has-n?            (re-find #".*\{0\}.*" format-string-esc)]
     (ttag/ngettext (ttag/msgid (clj->js strings) (if has-n? n ""))
-                   format-string-pl
+                   (-> format-string-pl
+                      escape-format-string
+                      (str/replace re-param-zero (str n)))
                    n)))
diff --git a/shared/test/metabase/shared/i18n_test.cljc b/shared/test/metabase/shared/i18n_test.cljc
new file mode 100644
index 0000000000000000000000000000000000000000..b5bb8f1215240a8f410521fbe71b4af469dc4f6e
--- /dev/null
+++ b/shared/test/metabase/shared/i18n_test.cljc
@@ -0,0 +1,28 @@
+(ns metabase.shared.i18n-test
+  (:require
+   [clojure.test :refer [are deftest is testing]]
+   [metabase.shared.util.i18n :as i18n]))
+
+(deftest ^:parallel tru-test
+  (testing "basic strings"
+    (is (= "some text here" (i18n/tru "some text here"))))
+  (testing "escaping single quotes"
+    (is (= "Where there's life there's hope, and need of vittles."
+           (i18n/tru "Where there''s life there''s hope, and need of vittles.")))))
+
+(deftest ^:parallel trun-test
+  (testing "basic"
+    (are [n exp] (= exp (i18n/trun "{0} cat" "{0} cats" n))
+      0 "0 cats"
+      1 "1 cat"
+      7 "7 cats"))
+  (testing "escaping in singular"
+    (are [n exp] (= exp (i18n/trun "{0} cat''s food" "{0} cats worth of food" n))
+      0 "0 cats worth of food"
+      1 "1 cat's food"
+      7 "7 cats worth of food"))
+  (testing "escaping in both"
+    (are [n exp] (= exp (i18n/trun "{0} cat''s food" "{0} cats'' food" n))
+         0 "0 cats' food"
+         1 "1 cat's food"
+         7 "7 cats' food")))
diff --git a/src/metabase/api/table.clj b/src/metabase/api/table.clj
index be028e5201af9293ddb60010d3c725a56142d7cd..27ff6393577435d74ba6f4d528234fa37742e4f8 100644
--- a/src/metabase/api/table.clj
+++ b/src/metabase/api/table.clj
@@ -270,6 +270,7 @@
 (defn- supports-numeric-binning? [db]
   (and db (driver/database-supports? (:engine db) :binning db)))
 
+;; TODO: Remove all this when the FE is fully ported to [[metabase.lib.binning/available-binning-strategies]].
 (defn- assoc-field-dimension-options [{:keys [base_type semantic_type fingerprint] :as field} db]
   (let [{min_value :min, max_value :max} (get-in fingerprint [:type :type/Number])
         [default-option all-options] (cond
diff --git a/src/metabase/lib/binning.cljc b/src/metabase/lib/binning.cljc
new file mode 100644
index 0000000000000000000000000000000000000000..c077f6fb870cee5449333eb19cf2532fb5517740
--- /dev/null
+++ b/src/metabase/lib/binning.cljc
@@ -0,0 +1,131 @@
+(ns metabase.lib.binning
+  (:require
+    [metabase.lib.dispatch :as lib.dispatch]
+    [metabase.lib.hierarchy :as lib.hierarchy]
+    [metabase.lib.metadata.calculation :as lib.metadata.calculation]
+    [metabase.lib.schema :as lib.schema]
+    [metabase.lib.schema.binning :as lib.schema.binning]
+    [metabase.shared.util.i18n :as i18n]
+    [metabase.util.malli :as mu]))
+
+(defmulti with-binning-method
+  "Implementation for [[with-binning]]. Implement this to tell [[with-binning]] how to add binning to a particular MBQL
+  clause."
+  {:arglists '([x binning])}
+  (fn [x _binning]
+    (lib.dispatch/dispatch-value x)) :hierarchy lib.hierarchy/hierarchy)
+
+(defmethod with-binning-method :dispatch-type/fn
+  [f binning]
+  (fn [query stage-number]
+    (let [x (f query stage-number)]
+      (with-binning-method x binning))))
+
+(mu/defn with-binning
+  "Add binning to an MBQL clause or something that can be converted to an MBQL clause.
+  Eg. for a Field or Field metadata or `:field` clause, this might do something like this:
+
+    (with-binning some-field (bin-by :num-bins 4))
+
+    =>
+
+    [:field {:binning {:strategy :num-bins :num-bins 4}} 1]
+
+  Pass `nil` `binning` to remove any binning."
+  [x binning :- [:maybe [:or ::lib.schema.binning/binning ::lib.schema.binning/binning-option]]]
+  (with-binning-method x (if (contains? binning :mbql)
+                           (:mbql binning)
+                           binning)))
+
+(defmulti binning-method
+  "Implementation of [[binning]]. Return the current binning options associated with `x`."
+  {:arglists '([x])}
+  lib.dispatch/dispatch-value
+  :hierarchy lib.hierarchy/hierarchy)
+
+(defmethod binning-method :default
+  [_x]
+  nil)
+
+(mu/defn binning :- [:maybe ::lib.schema.binning/binning]
+  "Get the current binning options associated with `x`, if any."
+  [x]
+  (binning-method x))
+
+(defmulti available-binning-strategies-method
+  "Implementation for [[available-binning-strategies]]. Return a set of binning strategies from
+  `:metabase.lib.schema.binning/binning-strategies` that are allowed to be used with `x`."
+  {:arglists '([query stage-number x])}
+  (fn [_query _stage-number x]
+    (lib.dispatch/dispatch-value x))
+  :hierarchy lib.hierarchy/hierarchy)
+
+(defmethod available-binning-strategies-method :default
+  [_query _stage-number _x]
+  nil)
+
+(mu/defn available-binning-strategies :- [:sequential [:ref ::lib.schema.binning/binning-option]]
+  "Get a set of available binning strategies for `x`. Returns nil if none are available."
+  ([query x]
+   (available-binning-strategies query -1 x))
+
+  ([query        :- ::lib.schema/query
+    stage-number :- :int
+    x]
+   (available-binning-strategies-method query stage-number x)))
+
+(defn- default-auto-bin []
+  {:display-name (i18n/tru "Auto bin")
+   :default      true
+   :mbql         {:strategy :default}})
+
+(defn- dont-bin []
+  {:display-name (i18n/tru "Don''t bin")
+   :mbql         nil})
+
+(defn- with-binning-option-type [m]
+  (assoc m :lib/type ::binning-option))
+
+(def ^:private *numeric-binning-strategies
+  (delay (mapv with-binning-option-type
+               [(default-auto-bin)
+                {:display-name (i18n/tru "10 bins")  :mbql {:strategy :num-bins :num-bins 10}}
+                {:display-name (i18n/tru "50 bins")  :mbql {:strategy :num-bins :num-bins 50}}
+                {:display-name (i18n/tru "100 bins") :mbql {:strategy :num-bins :num-bins 100}}
+                (dont-bin)])))
+
+(defn numeric-binning-strategies
+  "List of binning options for numeric fields. These split the data evenly into a fixed number of bins."
+  []
+  @*numeric-binning-strategies)
+
+(def ^:private *coordinate-binning-strategies
+  (delay
+    (mapv with-binning-option-type
+          [(default-auto-bin)
+           {:display-name (i18n/tru "Bin every 0.1 degrees") :mbql {:strategy :bin-width :bin-width 0.1}}
+           {:display-name (i18n/tru "Bin every 1 degree")    :mbql {:strategy :bin-width :bin-width 1.0}}
+           {:display-name (i18n/tru "Bin every 10 degrees")  :mbql {:strategy :bin-width :bin-width 10.0}}
+           {:display-name (i18n/tru "Bin every 20 degrees")  :mbql {:strategy :bin-width :bin-width 20.0}}
+           (dont-bin)])))
+
+(defn coordinate-binning-strategies
+  "List of binning options for coordinate fields (ie. latitude and longitude). These split the data into as many
+  ranges as necessary, with each range being a certain number of degrees wide."
+  []
+  @*coordinate-binning-strategies)
+
+(defmethod lib.metadata.calculation/display-info-method ::binning-option
+  [_query _stage-number binning-option]
+  (select-keys binning-option [:display-name :default]))
+
+(defn binning-display-name
+  "This is implemented outside of [[lib.metadata.calculation/display-name]] because it needs access to the field type.
+  It's called directly by `:field` or `:metadata/field`'s [[lib.metadata.calculation/display-name]]."
+  [{:keys [bin-width num-bins strategy] :as binning-options} field-metadata]
+  (when binning-options
+    (case strategy
+      :num-bins  (i18n/trun "{0} bin" "{0} bins" num-bins)
+      :bin-width (str bin-width (when (isa? (:semantic-type field-metadata) :type/Coordinate)
+                                  "°"))
+      :default   (i18n/tru "Auto binned"))))
diff --git a/src/metabase/lib/core.cljc b/src/metabase/lib/core.cljc
index 1309c52548bc4f943f810dddd84b0b287e63dbfa..151a49111b517490581ee002d7f1442a623205d5 100644
--- a/src/metabase/lib/core.cljc
+++ b/src/metabase/lib/core.cljc
@@ -5,6 +5,7 @@
                             + - * / time abs concat replace ref var])
   (:require
    [metabase.lib.aggregation :as lib.aggregation]
+   [metabase.lib.binning :as lib.binning]
    [metabase.lib.breakout :as lib.breakout]
    [metabase.lib.card :as lib.card]
    [metabase.lib.column-group :as lib.column-group]
@@ -29,6 +30,7 @@
    [metabase.shared.util.namespaces :as shared.ns]))
 
 (comment lib.aggregation/keep-me
+         lib.binning/keep-me
          lib.breakout/keep-me
          lib.card/keep-me
          lib.column-group/keep-me
@@ -67,6 +69,10 @@
    sum
    sum-where
    var]
+  [lib.binning
+   available-binning-strategies
+   binning
+   with-binning]
   [lib.breakout
    breakout
    breakoutable-columns
diff --git a/src/metabase/lib/field.cljc b/src/metabase/lib/field.cljc
index e0104a96d204a723d4c1dd2dd2484d9920f02a5d..46c00ee9f443fc637b6db375286685be92028fc8 100644
--- a/src/metabase/lib/field.cljc
+++ b/src/metabase/lib/field.cljc
@@ -2,11 +2,13 @@
   (:require
    [medley.core :as m]
    [metabase.lib.aggregation :as lib.aggregation]
+   [metabase.lib.binning :as lib.binning]
    [metabase.lib.expression :as lib.expression]
    [metabase.lib.join :as lib.join]
    [metabase.lib.metadata :as lib.metadata]
    [metabase.lib.metadata.calculation :as lib.metadata.calculation]
    [metabase.lib.normalize :as lib.normalize]
+   [metabase.lib.options :as lib.options]
    [metabase.lib.ref :as lib.ref]
    [metabase.lib.schema :as lib.schema]
    [metabase.lib.schema.common :as lib.schema.common]
@@ -17,6 +19,7 @@
    [metabase.lib.temporal-bucket :as lib.temporal-bucket]
    [metabase.lib.util :as lib.util]
    [metabase.shared.util.i18n :as i18n]
+   [metabase.util :as u]
    [metabase.util.humanization :as u.humanization]
    [metabase.util.log :as log]
    [metabase.util.malli :as mu]))
@@ -145,7 +148,9 @@
 
 ;;; TODO -- base type should be affected by `temporal-unit`, right?
 (defmethod lib.metadata.calculation/metadata-method :field
-  [query stage-number [_tag {:keys [source-field effective-type base-type temporal-unit join-alias], :as opts} :as field-ref]]
+  [query
+   stage-number
+   [_tag {:keys [base-type binning effective-type join-alias source-field temporal-unit], :as opts} :as field-ref]]
   (let [field-metadata (resolve-field-metadata query stage-number field-ref)
         metadata       (merge
                         {:lib/type :metadata/field}
@@ -158,6 +163,8 @@
                           {:base-type base-type})
                         (when temporal-unit
                           {::temporal-unit temporal-unit})
+                        (when binning
+                          {::binning binning})
                         (when join-alias
                           {::join-alias join-alias})
                         (when source-field
@@ -171,10 +178,11 @@
   [query stage-number {field-display-name :display-name
                        field-name         :name
                        temporal-unit      :unit
+                       binning            :binning
                        join-alias         :source_alias
                        fk-field-id        :fk-field-id
                        table-id           :table-id
-                       :as                _field-metadata} style]
+                       :as                field-metadata} style]
   (let [field-display-name (or field-display-name
                                (u.humanization/name->human-readable-name :simple field-name))
         join-display-name  (when (= style :long)
@@ -188,18 +196,20 @@
         display-name       (if join-display-name
                              (str join-display-name " → " field-display-name)
                              field-display-name)]
-    (if temporal-unit
-      (lib.util/format "%s (%s)" display-name (name temporal-unit))
-      display-name)))
+    (cond
+      temporal-unit (lib.util/format "%s (%s)" display-name (name temporal-unit))
+      binning       (lib.util/format "%s: %s"  display-name (lib.binning/binning-display-name binning field-metadata))
+      :else         display-name)))
 
 (defmethod lib.metadata.calculation/display-name-method :field
   [query
    stage-number
-   [_tag {:keys [join-alias temporal-unit source-field], :as _opts} _id-or-name, :as field-clause]
+   [_tag {:keys [binning join-alias temporal-unit source-field], :as _opts} _id-or-name, :as field-clause]
    style]
   (if-let [field-metadata (cond-> (resolve-field-metadata query stage-number field-clause)
                             join-alias    (assoc :source_alias join-alias)
                             temporal-unit (assoc :unit temporal-unit)
+                            binning       (assoc :binning binning)
                             source-field  (assoc :fk-field-id source-field))]
     (lib.metadata.calculation/display-name query stage-number field-metadata style)
     ;; mostly for the benefit of JS, which does not enforce the Malli schemas.
@@ -227,6 +237,7 @@
        (when-let [card (lib.metadata/card query card-id)]
          {:table {:name (:name card), :display-name (:name card)}})))))
 
+;;; ---------------------------------- Temporal Bucketing ----------------------------------------
 (defmethod lib.temporal-bucket/temporal-bucket-method :field
   [[_tag opts _id-or-name]]
   (:temporal-unit opts))
@@ -281,6 +292,43 @@
       (isa? effective-type :type/Time)     lib.temporal-bucket/time-bucket-options
       :else                                [])))
 
+;;; ---------------------------------------- Binning ---------------------------------------------
+(defmethod lib.binning/binning-method :field
+  [field-clause]
+  (-> field-clause lib.options/options :binning))
+
+(defmethod lib.binning/binning-method :metadata/field
+  [metadata]
+  (::binning metadata))
+
+(defmethod lib.binning/with-binning-method :field
+  [field-clause binning]
+  (lib.options/update-options field-clause u/assoc-dissoc :binning binning))
+
+(defmethod lib.binning/with-binning-method :metadata/field
+  [metadata binning]
+  (u/assoc-dissoc metadata ::binning binning))
+
+(defmethod lib.binning/available-binning-strategies-method :field
+  [query stage-number field-ref]
+  (lib.binning/available-binning-strategies query stage-number (resolve-field-metadata query stage-number field-ref)))
+
+(defmethod lib.binning/available-binning-strategies-method :metadata/field
+  [query _stage-number {:keys [effective-type fingerprint semantic-type] :as _field-metadata}]
+  (let [binning?        (some-> query lib.metadata/database :features (contains? :binning))
+        {min-value :min max-value :max} (get-in fingerprint [:type :type/Number])]
+    (cond
+      ;; TODO: Include the time and date binning strategies too; see metabase.api.table/assoc-field-dimension-options.
+      (and binning? min-value max-value
+           (isa? semantic-type :type/Coordinate))
+      (lib.binning/coordinate-binning-strategies)
+
+      (and binning? min-value max-value
+           (isa? effective-type :type/Number)
+           (not (isa? semantic-type :Relation/*)))
+      (lib.binning/numeric-binning-strategies))))
+
+;;; -------------------------------------- Join Alias --------------------------------------------
 (defmethod lib.join/current-join-alias-method :field
   [[_tag opts]]
   (get opts :join-alias))
@@ -316,10 +364,10 @@
                               {:join-alias join-alias})
                             (when-let [temporal-unit (::temporal-unit metadata)]
                               {:temporal-unit temporal-unit})
+                            (when-let [binning (::binning metadata)]
+                              {:binning binning})
                             (when-let [source-field-id (:fk-field-id metadata)]
-                              {:source-field source-field-id})
-                            ;; TODO -- binning options.
-                            )
+                              {:source-field source-field-id}))
           always-use-name? (#{:source/card :source/native :source/previous-stage} (:lib/source metadata))]
       [:field options (if always-use-name?
                         (:name metadata)
diff --git a/src/metabase/lib/options.cljc b/src/metabase/lib/options.cljc
index e83ff49ac13e564e9ea59c294acd44748e45b09f..790472edc44c3b5dc8390999b9d309e132d20a8b 100644
--- a/src/metabase/lib/options.cljc
+++ b/src/metabase/lib/options.cljc
@@ -1,6 +1,7 @@
 (ns metabase.lib.options
   (:require
    [metabase.shared.util.i18n :as i18n]
+   [metabase.util :as u]
    [metabase.util.malli :as mu]))
 
 ;;; TODO -- not 100% sure we actually need all of this stuff anymore.
@@ -40,7 +41,9 @@
 
 (mu/defn with-options
   "Update `x` so its [[options]] are `new-options`. If the clause or map already has options, this will
-  *replace* the old options; if it does not, this will the new options.
+  *replace* the old options; if it does not, this will set the new options.
+
+  If `x` is a map with `:lib/options` and `new-options` is `empty?`, this will drop `:lib/options` entirely.
 
   You should probably prefer [[update-options]] to using this directly, so you don't stomp over existing stuff
   unintentionally. Implement this if you need to teach Metabase lib how to support something that doesn't follow the
@@ -48,7 +51,7 @@
   [x new-options :- [:maybe map?]]
   (cond
     (map? x)
-    (assoc x :lib/options new-options)
+    (u/assoc-dissoc x :lib/options (not-empty new-options))
 
     (mbql-clause? x)
     (if ((some-fn nil? map?) (second x))
diff --git a/src/metabase/lib/schema/binning.cljc b/src/metabase/lib/schema/binning.cljc
new file mode 100644
index 0000000000000000000000000000000000000000..29097c9edfe99de033d444c8f73012a970b40ac5
--- /dev/null
+++ b/src/metabase/lib/schema/binning.cljc
@@ -0,0 +1,30 @@
+(ns metabase.lib.schema.binning
+  "Malli schema for binning of a column's values.
+
+  There are two approaches to binning, selected by `:strategy`:
+  - `{:strategy :bin-width :bin-width 10}` makes 1 or more bins that are 10 wide;
+  - `{:strategy :num-bins  :num-bins  12}` splits the column into 12 bins."
+  (:require
+   [metabase.lib.schema.common :as lib.schema.common]
+   [metabase.util.malli.registry :as mr]))
+
+(mr/def ::binning-strategies
+  [:enum :bin-width :default :num-bins])
+
+(mr/def ::binning
+  [:and
+   [:map
+    [:strategy  [:ref ::binning-strategies]]
+    [:bin-width {:optional true} pos?]
+    [:num-bins  {:optional true} ::lib.schema.common/int-greater-than-zero]]
+   [:fn {:error/message "if :strategy is not :default, the matching key :bin-width or :num-bins must also be set"}
+    #(when-let [strat (:strategy %)]
+       (or (= strat :default)
+           (contains? % strat)))]])
+
+(mr/def ::binning-option
+  [:map
+   [:lib/type [:= :metabase.lib.binning/binning-option]]
+   [:display-name :string]
+   [:mbql [:maybe ::binning]]
+   [:default {:optional true} :boolean]])
diff --git a/src/metabase/util.cljc b/src/metabase/util.cljc
index 255f1b555e9b69b3d8b6fb9fb531d83eac408c4e..8e012a9e47da2bfea8c5ad7ed1d621fa6b8aa867 100644
--- a/src/metabase/util.cljc
+++ b/src/metabase/util.cljc
@@ -773,3 +773,14 @@
     (regexp? x)     :dispatch-type/regex
     ;; we should add more mappings here as needed
     :else           :dispatch-type/*))
+
+(defn assoc-dissoc
+  "Called like `(assoc m k v)`, this does [[assoc]] if `(some? v)`, and [[dissoc]] if not.
+
+  Put another way: `k` will either be set to `v`, or removed.
+
+  Note that if `v` is `false`, it will be handled with [[assoc]]; only `nil` causes a [[dissoc]]."
+  [m k v]
+  (if (some? v)
+    (assoc m k v)
+    (dissoc m k)))
diff --git a/test/metabase/lib/field_test.cljc b/test/metabase/lib/field_test.cljc
index 7c4521b04697474d6b3001025fe8659aadea4942..b0231f3a54a87d489ad76c1a874004e47aad815c 100644
--- a/test/metabase/lib/field_test.cljc
+++ b/test/metabase/lib/field_test.cljc
@@ -2,6 +2,7 @@
   (:require
    [clojure.test :refer [are deftest is testing]]
    [medley.core :as m]
+   [metabase.lib.binning :as lib.binning]
    [metabase.lib.core :as lib]
    [metabase.lib.metadata :as lib.metadata]
    [metabase.lib.metadata.calculation :as lib.metadata.calculation]
@@ -228,6 +229,80 @@
                    (lib/available-temporal-buckets (:query temporal-bucketing-mock-metadata)
                                                    (lib/with-temporal-bucket x :month-of-year))))))))))
 
+(deftest ^:parallel unresolved-lib-field-with-binning-test
+  (let [query   (lib/query-for-table-name meta/metadata-provider "ORDERS")
+        binning {:strategy :num-bins
+                 :num-bins 10}
+        f       (lib/with-binning (lib/field (meta/id :orders :subtotal)) binning)]
+    (is (fn? f))
+    (let [field (f query -1)]
+      (is (=? [:field {:binning binning} (meta/id :orders :subtotal)]
+              field))
+      (testing "(lib/binning <column-metadata>)"
+        (is (= binning
+               (lib/binning (lib.metadata.calculation/metadata query -1 field)))))
+      (testing "(lib/binning <field-ref>)"
+        (is (= binning
+               (lib/binning field))))
+      #?(:clj
+         ;; i18n/trun doesn't work in the CLJS tests, only in proper FE, so this test is JVM-only.
+         (is (= "Subtotal: 10 bins"
+                (lib.metadata.calculation/display-name query -1 field)))))))
+
+(deftest ^:parallel with-binning-test
+  (doseq [[binning1 binning2] (partition 2 1 [{:strategy :default}
+                                              {:strategy :num-bins  :num-bins  10}
+                                              {:strategy :bin-width :bin-width 1.0}
+                                              {:strategy :default}])
+          :let                  [field-metadata (lib.metadata/field meta/metadata-provider "PUBLIC" "ORDERS" "SUBTOTAL")]
+          [what x]              {"column metadata" field-metadata
+                                 "field ref"       (lib/ref field-metadata)}
+          :let                  [x' (lib/with-binning x binning1)]]
+    (testing (str what " strategy = " (:strategy binning2) "\n\n" (u/pprint-to-str x') "\n")
+      (testing "lib/binning should return the binning settings"
+        (is (= binning1
+               (lib/binning x'))))
+      (testing "should generate a :field ref with correct :binning"
+        (is (=? [:field
+                 {:lib/uuid string?
+                  :binning  binning1}
+                 integer?]
+                (lib/ref x'))))
+      (testing "remove the binning setting"
+        (let [x'' (lib/with-binning x' nil)]
+          (is (nil? (lib/binning x'')))
+          (is (= x
+                 x''))))
+      (testing "change the binning setting, THEN remove it"
+        (let [x''  (lib/with-binning x' binning2)
+              x''' (lib/with-binning x'' nil)]
+          (is (= binning2
+                 (lib/binning x'')))
+          (is (nil? (lib/binning x''')))
+          (is (= x
+                 x''')))))))
+
+(deftest ^:parallel available-binning-strategies-test
+  (doseq [{:keys [expected-options field-metadata query]}
+          [{:query            (lib/query-for-table-name meta/metadata-provider "ORDERS")
+            :field-metadata   (lib.metadata/field meta/metadata-provider "PUBLIC" "ORDERS" "SUBTOTAL")
+            :expected-options (lib.binning/numeric-binning-strategies)}
+           {:query            (lib/query-for-table-name meta/metadata-provider "PEOPLE")
+            :field-metadata   (lib.metadata/field meta/metadata-provider "PUBLIC" "PEOPLE" "LATITUDE")
+            :expected-options (lib.binning/coordinate-binning-strategies)}]]
+    (testing (str (:semantic-type field-metadata) " Field")
+      (doseq [[what x] [["column metadata" field-metadata]
+                        ["field ref"       (lib/ref field-metadata)]]]
+        (testing (str what "\n\n" (u/pprint-to-str x))
+          (is (= expected-options
+                 (lib/available-binning-strategies query x)))
+          (testing "when binned, should still return the same available units"
+            (let [binned (lib/with-binning x (second expected-options))]
+              (is (= (-> expected-options second :mbql)
+                     (lib/binning binned)))
+              (is (= expected-options
+                     (lib/available-binning-strategies query binned))))))))))
+
 (deftest ^:parallel joined-field-column-name-test
   (let [card  {:dataset-query {:database (meta/id)
                                :type     :query
diff --git a/test/metabase/util_test.cljc b/test/metabase/util_test.cljc
index a782ada2dc1229b825941b5a9a34efd854721ff9..bbe534b68540b55a9943ba5cd148b94facfdc622 100644
--- a/test/metabase/util_test.cljc
+++ b/test/metabase/util_test.cljc
@@ -381,3 +381,13 @@
       :dispatch-type/regex
       :dispatch-type/fn
       :dispatch-type/*)))
+
+(deftest ^:parallel assoc-dissoc-test
+  (testing `lib.options/with-option-value
+    (is (= {:foo "baz"}
+           (u/assoc-dissoc {:foo "bar"} :foo "baz")))
+    (is (= {}
+           (u/assoc-dissoc {:foo "bar"} :foo nil)))
+    (is (= {:foo false}
+           (u/assoc-dissoc {:foo "bar"} :foo false))
+        "false should be assoc'd")))