diff --git a/dvc/repo/experiments/executor/base.py b/dvc/repo/experiments/executor/base.py
--- a/dvc/repo/experiments/executor/base.py
+++ b/dvc/repo/experiments/executor/base.py
@@ -376,7 +376,7 @@ def commit(
             logger.debug("Commit to new experiment branch '%s'", branch)
 
         scm.gitpython.repo.git.add(update=True)
-        scm.commit(f"dvc: commit experiment {exp_hash}")
+        scm.commit(f"dvc: commit experiment {exp_hash}", no_verify=True)
         new_rev = scm.get_rev()
         scm.set_ref(branch, new_rev, old_ref=old_ref)
         scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
diff --git a/dvc/scm/git/backend/base.py b/dvc/scm/git/backend/base.py
--- a/dvc/scm/git/backend/base.py
+++ b/dvc/scm/git/backend/base.py
@@ -56,7 +56,7 @@ def add(self, paths: Iterable[str]):
         pass
 
     @abstractmethod
-    def commit(self, msg: str):
+    def commit(self, msg: str, no_verify: bool = False):
         pass
 
     @abstractmethod
diff --git a/dvc/scm/git/backend/dulwich.py b/dvc/scm/git/backend/dulwich.py
--- a/dvc/scm/git/backend/dulwich.py
+++ b/dvc/scm/git/backend/dulwich.py
@@ -145,10 +145,14 @@ def add(self, paths: Iterable[str]):
             # NOTE: this doesn't check gitignore, same as GitPythonBackend.add
             self.repo.stage(relpath(fpath, self.root_dir))
 
-    def commit(self, msg: str):
+    def commit(self, msg: str, no_verify: bool = False):
+        from dulwich.errors import CommitError
         from dulwich.porcelain import commit
 
-        commit(self.root_dir, message=msg)
+        try:
+            commit(self.root_dir, message=msg, no_verify=no_verify)
+        except CommitError as exc:
+            raise SCMError("Git commit failed") from exc
 
     def checkout(
         self, branch: str, create_new: Optional[bool] = False, **kwargs,
diff --git a/dvc/scm/git/backend/gitpython.py b/dvc/scm/git/backend/gitpython.py
--- a/dvc/scm/git/backend/gitpython.py
+++ b/dvc/scm/git/backend/gitpython.py
@@ -200,8 +200,13 @@ def add(self, paths: Iterable[str]):
 
             logger.exception(msg)
 
-    def commit(self, msg: str):
-        self.repo.index.commit(msg)
+    def commit(self, msg: str, no_verify: bool = False):
+        from git.exc import HookExecutionError
+
+        try:
+            self.repo.index.commit(msg, skip_hooks=no_verify)
+        except HookExecutionError as exc:
+            raise SCMError("Git pre-commit hook failed") from exc
 
     def checkout(
         self, branch: str, create_new: Optional[bool] = False, **kwargs
diff --git a/dvc/scm/git/backend/pygit2.py b/dvc/scm/git/backend/pygit2.py
--- a/dvc/scm/git/backend/pygit2.py
+++ b/dvc/scm/git/backend/pygit2.py
@@ -86,7 +86,7 @@ def dir(self) -> str:
     def add(self, paths: Iterable[str]):
         raise NotImplementedError
 
-    def commit(self, msg: str):
+    def commit(self, msg: str, no_verify: bool = False):
         raise NotImplementedError
 
     def checkout(
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -50,7 +50,7 @@ def run(self):
     "colorama>=0.3.9",
     "configobj>=5.0.6",
     "gitpython>3",
-    "dulwich>=0.20.14",
+    "dulwich>=0.20.15",
     "pygit2>=1.4.0",
     "setuptools>=34.0.0",
     "nanotime>=0.5.2",

diff --git a/tests/unit/scm/test_git.py b/tests/unit/scm/test_git.py
--- a/tests/unit/scm/test_git.py
+++ b/tests/unit/scm/test_git.py
@@ -301,3 +301,23 @@ def test_ignore_remove_empty(tmp_dir, scm, git):
 
     git.ignore_remove(test_entries[1]["path"])
     assert not path_to_gitignore.exists()
+
+
+@pytest.mark.skipif(
+    os.name == "nt", reason="Git hooks not supported on Windows"
+)
+@pytest.mark.parametrize("hook", ["pre-commit", "commit-msg"])
+def test_commit_no_verify(tmp_dir, scm, git, hook):
+    import stat
+
+    hook_file = os.path.join(".git", "hooks", hook)
+    tmp_dir.gen(
+        hook_file, "#!/usr/bin/env python\nimport sys\nsys.exit(1)",
+    )
+    os.chmod(hook_file, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
+
+    tmp_dir.gen("foo", "foo")
+    git.add(["foo"])
+    with pytest.raises(SCMError):
+        git.commit("commit foo")
+    git.commit("commit foo", no_verify=True)

experiments: commit in executor will fail if `pre-commit` or `commit-msg` hooks are enabled
# Bug Report

dulwich hook classes are not pickable, so if either hook is enabled you will currently get:
```
Traceback (most recent call last):
  File "/usr/lib64/python3.7/concurrent/futures/process.py", line 205, in _sendback_result
    exception=exception))
  File "/usr/lib64/python3.7/multiprocessing/queues.py", line 358, in put
    obj = _ForkingPickler.dumps(obj)
  File "/usr/lib64/python3.7/multiprocessing/reduction.py", line 51, in dumps
    cls(buf, protocol).dump(obj)
AttributeError: Can't pickle local object 'CommitMsgShellHook.__init__.<locals>.prepare_msg'
```

We should be committing with `--no-verify` in experiment executors, since our auto-generated experiment commit messages and commit contents will likely not pass (by design) the user's checks. (So even if we can call commit successfully using gitpython instead of dulwich here, the underlying issue would still exist.)

This will require an upstream patch for dulwich backend.

tests/unit/scm/test_git.py::test_commit_no_verify[gitpython-commit-msg] tests/unit/scm/test_git.py::test_commit_no_verify[dulwich-commit-msg] tests/unit/scm/test_git.py::test_commit_no_verify[gitpython-pre-commit] tests/unit/scm/test_git.py::test_commit_no_verify[dulwich-pre-commit]
tests/unit/scm/test_git.py::TestGit::test_belongs_to_scm_true_on_gitignore tests/unit/scm/test_git.py::test_remove_ref[gitpython] tests/unit/scm/test_git.py::test_push_refspec[False] tests/unit/scm/test_git.py::TestGit::test_belongs_to_scm_false tests/unit/scm/test_git.py::test_fetch_refspecs tests/unit/scm/test_git.py::test_is_tracked tests/unit/scm/test_git.py::test_walk_with_submodules tests/unit/scm/test_git.py::test_branch_revs tests/unit/scm/test_git.py::test_ignore_remove_empty[dulwich] tests/unit/scm/test_git.py::test_ignore_remove_empty[gitpython] tests/unit/scm/test_git.py::test_set_ref[gitpython] tests/unit/scm/test_git.py::test_get_ref[dulwich] tests/unit/scm/test_git.py::test_list_all_commits tests/unit/scm/test_git.py::test_get_ref[gitpython] tests/unit/scm/test_git.py::TestGit::test_belongs_to_scm_true_on_git_internal tests/unit/scm/test_git.py::test_set_ref[dulwich] tests/unit/scm/test_git.py::test_push_refspec[True] tests/unit/scm/test_git.py::test_walk_onerror tests/unit/scm/test_git.py::test_is_tracked_unicode tests/unit/scm/test_git.py::test_remove_ref[dulwich] tests/unit/scm/test_git.py::test_refs_containing tests/unit/scm/test_git.py::test_no_commits