diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj
index 8e52656849fafc5acf70d7988a704c96cf60b134..a877ccf5f46dd8ebc099d5f97723824791e39cb5 100644
--- a/src/metabase/api/collection.clj
+++ b/src/metabase/api/collection.clj
@@ -10,6 +10,8 @@
    [compojure.core :refer [GET POST PUT]]
    [honeysql.core :as hsql]
    [honeysql.helpers :as hh]
+   [malli.core :as mc]
+   [malli.transform :as mtx]
    [medley.core :as m]
    [metabase.api.card :as api.card]
    [metabase.api.common :as api]
@@ -881,33 +883,49 @@
   (api/check-superuser)
   (graph/graph namespace))
 
-(defn- ->int [id] (Integer/parseInt (name id)))
+(def CollectionID "an id for a [[Collection]]."
+  [pos-int? {:title "Collection ID"}])
 
-(defn- dejsonify-collections [collections]
-  (into {} (for [[collection-id perms] collections]
-             [(if (= (keyword collection-id) :root)
-                :root
-                (->int collection-id))
-              (keyword perms)])))
+(def GroupID "an id for a [[PermissionsGroup]]."
+  [pos-int? {:title "Group ID"}])
 
-(defn- dejsonify-groups [groups]
-  (into {} (for [[group-id collections] groups]
-             {(->int group-id) (dejsonify-collections collections)})))
+(def CollectionPermissions
+  "Malli enum for what sort of collection permissions we have. (:write :read or :none)"
+  [:and keyword? [:enum :write :read :none]])
 
-(defn- dejsonify-graph
-  "Fix the types in the graph when it comes in from the API, e.g. converting things like `\"none\"` to `:none` and
-  parsing object keys as integers."
-  [graph]
-  (update graph :groups dejsonify-groups))
+(def GroupPermissionsGraph
+  "Map describing permissions for a (Group x Collection)"
+  [:map-of
+   [:or
+    ;; We need the [:and keyword ...] piece to make decoding "root" work. There's a merged fix for this, but it hasn't
+    ;; been released as of malli 0.9.2. When the malli version gets bumped, we should remove this.
+    [:and keyword? [:= :root]]
+    CollectionID]
+   CollectionPermissions])
+
+(def PermissionsGraph
+  "Map describing permissions for 1 or more groups.
+  Revision # is used for consistency"
+  [:map
+   [:revision int?]
+   [:groups [:map-of GroupID GroupPermissionsGraph]]])
+
+(def ^:private graph-decoder
+  "Building it this way is a lot faster then calling mc/decode <value> <schema> <transformer>"
+  (mc/decoder PermissionsGraph (mtx/string-transformer)))
+
+(defn- decode-graph [permission-graph]
+  (graph-decoder permission-graph))
 
 (api/defendpoint-schema PUT "/graph"
-  "Do a batch update of Collections Permissions by passing in a modified graph."
+  "Do a batch update of Collections Permissions by passing in a modified graph.
+  Will overwrite parts of the graph that are present in the request, and leave the rest unchanged."
   [:as {{:keys [namespace], :as body} :body}]
   {body      su/Map
    namespace (s/maybe su/NonBlankString)}
   (api/check-superuser)
   (->> (dissoc body :namespace)
-       dejsonify-graph
+       decode-graph
        (graph/update-graph! namespace))
   (graph/graph namespace))