Repository: copier-org/copier (2024-04-18 ~ 2025-07-30)

problem_statement: bug: `copier update` throws error if previously selected value is  disabled
### Describe the problem

When trying to update the project created from template, we ran into the scenario where previously selected value choice was disabled, causing an error.

We have a setup similar to this:
```
version:
  type: str
  help: Choose your Python version
  choices:
    - "3.8"
    - "3.9"

image:
  type: str
  help: Choose a base Docker image
  choices:
    slim (python==3.8):
      value: slim-3.8
      validator: '{% if version == "3.8" %}{% else %}does not apply{% endif %}'
    bullseye (python==3.8):
      value: bullseye-3.8
      validator: '{% if version == "3.8" %}{% else %}does not apply{% endif %}'
    alpine (python==3.9):
      value: alpine-3.9
      validator: '{% if version == "3.9" %}{% else %}does not apply{% endif %}'
    bullseye (python==3.9):
      value: bullseye-3.9
      validator: '{% if version == "3.9" %}{% else %}does not apply{% endif %}'
```

Basic idea is to show different docker images based on previous versions, and there might be scenario where some images only available only certain versions.

What would be best way to overcome this error? Is this a bug on `copier` side?

### Template

Minimal examples to reproduce the issue:
- template: https://github.com/MantasPetrikasVinted/copier-playground
- filled project: https://github.com/MantasPetrikasVinted/copier-playground-filled

### To Reproduce

1. Run `copier copy git@github.com:MantasPetrikasVinted/copier-playground.git`
2. Select `3.8` and `slim-3.8`
3. Run `copier update`
4. Select `3.9`
5. Get error message

or 

1. Run `git clone git@github.com:MantasPetrikasVinted/copier-playground-filled`
2. Run `copier update`
3. Select `3.9`
4. Get error message




### Logs

```console
copier-playground-filled git:(main) copier update                                                          
No git tags found in template; using HEAD as ref
Updating to template version 0.0.0.post3.dev0+1b21cd9
No git tags found in template; using HEAD as ref
🎤 Choose your Python version
   3.9
Traceback (most recent call last):
  File "/Users/mantas/.local/bin/copier", line 8, in <module>
    sys.exit(copier_app_run())
             ^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/plumbum/cli/application.py", line 638, in run
    inst, retcode = subapp.run(argv, exit=False)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/plumbum/cli/application.py", line 633, in run
    retcode = inst.main(*tailargs)
              ^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/decorator.py", line 232, in fun
    return caller(func, *(extras + args), **kw)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 71, in handle_exceptions
    return method(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 402, in main
    with self._worker(
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 205, in __exit__
    raise value
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 410, in main
    worker.run_update()
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 826, in run_update
    self._apply_update()
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 882, in _apply_update
    with replace(
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 205, in __exit__
    raise value
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 890, in _apply_update
    current_worker.run_copy()
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 745, in run_copy
    self._ask()
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 454, in _ask
    new_answer = unsafe_prompt(
                 ^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/questionary/prompt.py", line 221, in unsafe_prompt
    question = create_question_func(**_kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/questionary/prompts/select.py", line 146, in select
    ic = InquirerControl(
         ^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/questionary/prompts/common.py", line 274, in __init__
    raise ValueError(
ValueError: Invalid 'initial_choice' value ('<questionary.prompts.common.Choice object at 0x108887410>'). It must be a selectable value.
```


### Expected behavior

First selectable value is selected


### Operating system distribution and version

macOS Ventura 13.6

### Copier version

copier 9.1.1

### Python version

Python 3.9.17

### Installation method

pipx+pypi
date: 2024-04-18
patch: diff --git a/copier/main.py b/copier/main.py
index 277f01d..0a4b95d 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -418,7 +418,7 @@ class Worker:
             return True
         return self._solve_render_conflict(dst_relpath)
 
-    def _ask(self) -> None:
+    def _ask(self) -> None:  # noqa: C901
         """Ask the questions of the questionnaire and record their answers."""
         result = AnswersMap(
             user_defaults=self.user_defaults,
@@ -434,8 +434,18 @@ class Worker:
                 var_name=var_name,
                 **details,
             )
+            # Delete last answer if it cannot be parsed or validated, so a new
+            # valid answer can be provided.
+            if var_name in result.last:
+                try:
+                    answer = question.parse_answer(result.last[var_name])
+                except Exception:
+                    del result.last[var_name]
+                else:
+                    if question.validate_answer(answer):
+                        del result.last[var_name]
+            # Skip a question when the user already answered it.
             if self.skip_answered and var_name in result.last:
-                # Skip a question when the user already answered it.
                 continue
             # Skip a question when the skip condition is met.
             if not question.get_when():

test_patch: diff --git a/tests/test_templated_prompt.py b/tests/test_templated_prompt.py
index 2a1b124..bccf0d2 100644
--- a/tests/test_templated_prompt.py
+++ b/tests/test_templated_prompt.py
@@ -8,6 +8,8 @@ import pexpect
 import pytest
 import yaml
 from pexpect.popen_spawn import PopenSpawn
+from plumbum import local
+from plumbum.cmd import git
 
 from copier import Worker
 from copier.errors import InvalidTypeError
@@ -18,9 +20,11 @@ from .helpers import (
     BRACKET_ENVOPS_JSON,
     COPIER_PATH,
     SUFFIX_TMPL,
+    Keyboard,
     Spawn,
     build_file_tree,
     expect_prompt,
+    git_init,
 )
 
 main_default = "copier"
@@ -364,6 +368,84 @@ def test_templated_prompt_with_conditional_choices(
     tui.sendline()
 
 
+def test_templated_prompt_update_previous_answer_disabled(
+    tmp_path_factory: pytest.TempPathFactory, spawn: Spawn
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            (src / "copier.yml"): (
+                """\
+                cloud:
+                    type: str
+                    help: Which cloud provider do you use?
+                    choices:
+                        - Any
+                        - AWS
+                        - Azure
+                        - GCP
+                iac:
+                    type: str
+                    help: Which IaC tool do you use?
+                    choices:
+                        Terraform: tf
+                        Cloud Formation:
+                            value: cf
+                            validator: "{% if cloud != 'AWS' %}Requires AWS{% endif %}"
+                        Azure Resource Manager:
+                            value: arm
+                            validator: "{% if cloud != 'Azure' %}Requires Azure{% endif %}"
+                        Deployment Manager:
+                            value: dm
+                            validator: "{% if cloud != 'GCP' %}Requires GCP{% endif %}"
+                """
+            ),
+            (src / "{{ _copier_conf.answers_file }}.jinja"): (
+                "{{ _copier_answers|to_nice_yaml }}"
+            ),
+        }
+    )
+
+    with local.cwd(src):
+        git_init("v1")
+        git("tag", "v1")
+
+    tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10)
+    expect_prompt(tui, "cloud", "str", help="Which cloud provider do you use?")
+    tui.sendline(Keyboard.Down)  # select "AWS"
+    expect_prompt(tui, "iac", "str", help="Which IaC tool do you use?")
+    tui.sendline(Keyboard.Down)  # select "Cloud Formation"
+    tui.expect_exact(pexpect.EOF)
+
+    answers_file = dst / ".copier-answers.yml"
+
+    assert answers_file.exists()
+    assert yaml.safe_load(answers_file.read_text()) == {
+        "_src_path": str(src),
+        "_commit": "v1",
+        "cloud": "AWS",
+        "iac": "cf",
+    }
+
+    with local.cwd(dst):
+        git_init("v1")
+
+    tui = spawn(COPIER_PATH + ("update", str(dst)), timeout=10)
+    expect_prompt(tui, "cloud", "str", help="Which cloud provider do you use?")
+    tui.sendline(Keyboard.Down)  # select "Azure"
+    expect_prompt(tui, "iac", "str", help="Which IaC tool do you use?")
+    tui.sendline()  # select "Terraform" (first supported)
+    tui.expect_exact(pexpect.EOF)
+
+    assert answers_file.exists()
+    assert yaml.safe_load(answers_file.read_text()) == {
+        "_src_path": str(src),
+        "_commit": "v1",
+        "cloud": "Azure",
+        "iac": "tf",
+    }
+
+
 def test_multiselect_choices_with_templated_default_value(
     tmp_path_factory: pytest.TempPathFactory,
     spawn: Spawn,


----

problem_statement: `validator` checks are skipped for questions with a `default` when `--defaults` option is set
### Describe the problem

When using the `--defaults` option the `validator` checks are not executed when a question sets a default and a value isn't explicitly set.

### Template

```yaml
---
title:
  type: str
  help: The human readable title
  validator: "{% if title == '' %}Title must not be empty{% endif %}"

name:
  type: str
  help: Name in kebab-case
  default: "{{ title.lower().replace(' ', '-') }}"
  validator: "{% if not (name | regex_search('^[a-z]+(-[a-z]+)*$')) %}Invalid name{% endif %}"
```

### To Reproduce

1. Create the `copier.yaml` in a directory named `template`
2. `mkdir dest`
3. `copier copy --defaults -d title=@ template foo`

### Logs

```console

```

### Expected behavior

I would expect that the `validator` logic is always run on the value of a question.

### Screenshots/screencasts/logs

_No response_

### Operating system

macOS

### Operating system distribution and version

macOS 15.4.1

### Copier version

9.6.0

### Python version

Python 3.10.13

### Installation method

distro package

### Additional context

_No response_
date: 2025-05-06
patch: diff --git a/copier/main.py b/copier/main.py
index 0a4b95d..eca5e48 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -439,11 +439,9 @@ class Worker:
             if var_name in result.last:
                 try:
                     answer = question.parse_answer(result.last[var_name])
+                    question.validate_answer(answer)
                 except Exception:
                     del result.last[var_name]
-                else:
-                    if question.validate_answer(answer):
-                        del result.last[var_name]
             # Skip a question when the user already answered it.
             if self.skip_answered and var_name in result.last:
                 continue
@@ -456,12 +454,10 @@ class Worker:
                 if question.default is MISSING:
                     continue
             if var_name in result.init:
-                # Try to parse the answer value.
+                # Try to parse and validate (if the question has a validator)
+                # the answer value.
                 answer = question.parse_answer(result.init[var_name])
-                # Try to validate the answer value if the question has a
-                # validator.
-                if err_msg := question.validate_answer(answer):
-                    raise ValueError(err_msg)
+                question.validate_answer(answer)
                 # At this point, the answer value is valid. Do not ask the
                 # question again, but set answer as the user's answer instead.
                 result.user[var_name] = answer
diff --git a/copier/user_data.py b/copier/user_data.py
index ad68f16..1276b75 100644
--- a/copier/user_data.py
+++ b/copier/user_data.py
@@ -4,6 +4,7 @@ from __future__ import annotations
 import json
 import warnings
 from collections import ChainMap
+from copy import deepcopy
 from dataclasses import field
 from datetime import datetime
 from functools import cached_property
@@ -246,7 +247,13 @@ class Question:
                     if self.default is MISSING:
                         return MISSING
                     result = self.render_value(self.default)
-        result = self.cast_answer(result)
+        result = self.parse_answer(result)
+        # Computed values (i.e., `when: false`) are intentionally not validated
+        # at the moment.
+        # https://github.com/copier-org/copier/issues/1779#issuecomment-2365006990
+        # https://github.com/copier-org/copier/pull/1785
+        if self.get_when():
+            self.validate_answer(result)
         return result
 
     def get_default_rendered(self) -> bool | str | Choice | None | MissingType:
@@ -290,7 +297,6 @@ class Question:
         """Obtain choices rendered and properly formatted."""
         result = []
         choices = self.choices
-        default = self.get_default()
         if isinstance(self.choices, dict):
             choices = list(self.choices.items())
         for choice in choices:
@@ -351,7 +357,11 @@ class Question:
                 ans = self.parse_answer(answer)
             except Exception:
                 return "Invalid input"
-            return self.validate_answer(ans) or True
+            try:
+                self.validate_answer(ans)
+                return True
+            except ValueError as e:
+                return str(e)
 
         lexer = None
         result: AnyByStrDict = {
@@ -405,15 +415,16 @@ class Question:
         """Get the value for multiline."""
         return cast_to_bool(self.render_value(self.multiline))
 
-    def validate_answer(self, answer: Any) -> str:
+    def validate_answer(self, answer: Any) -> None:
         """Validate user answer."""
         try:
             err_msg = self.render_value(self.validator, {self.var_name: answer}).strip()
         except Exception as error:
-            return str(error)
+            err_msg = str(error)
         if err_msg:
-            return err_msg
-        return ""
+            raise ValueError(
+                f"Validation error for question '{self.var_name}': {err_msg}"
+            )
 
     def get_when(self) -> bool:
         """Get skip condition for question."""

test_patch: diff --git a/tests/test_copy.py b/tests/test_copy.py
index 2d93083..aeb64eb 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -831,84 +831,58 @@ def test_required_choice_question_without_data(
 
 
 @pytest.mark.parametrize(
-    "type_name, default, expected",
+    "spec, expected",
     [
-        ("str", "string", does_not_raise()),
-        ("str", "1.0", does_not_raise()),
-        ("str", 1.0, does_not_raise()),
-        ("str", None, pytest.raises(TypeError)),
-        ("int", 1, does_not_raise()),
-        ("int", 1.0, does_not_raise()),
-        ("int", "1", does_not_raise()),
-        ("int", "1.0", pytest.raises(ValueError)),
-        ("int", "no-int", pytest.raises(ValueError)),
-        ("int", None, pytest.raises(TypeError)),
-        ("int", {}, pytest.raises(TypeError)),
-        ("int", [], pytest.raises(TypeError)),
-        ("float", 1.1, does_not_raise()),
-        ("float", 1, does_not_raise()),
-        ("float", "1.1", does_not_raise()),
-        ("float", "no-float", pytest.raises(ValueError)),
-        ("float", None, pytest.raises(TypeError)),
-        ("float", {}, pytest.raises(TypeError)),
-        ("float", [], pytest.raises(TypeError)),
-        ("bool", True, does_not_raise()),
-        ("bool", False, does_not_raise()),
-        ("bool", "y", does_not_raise()),
-        ("bool", "n", does_not_raise()),
-        ("bool", None, pytest.raises(TypeError)),
-        ("json", '"string"', does_not_raise()),
-        ("json", "1", does_not_raise()),
-        ("json", 1, does_not_raise()),
-        ("json", "1.1", does_not_raise()),
-        ("json", 1.1, does_not_raise()),
-        ("json", "true", does_not_raise()),
-        ("json", True, does_not_raise()),
-        ("json", "false", does_not_raise()),
-        ("json", False, does_not_raise()),
-        ("json", "{}", does_not_raise()),
-        ("json", {}, does_not_raise()),
-        ("json", "[]", does_not_raise()),
-        ("json", [], does_not_raise()),
-        ("json", "null", does_not_raise()),
-        ("json", None, does_not_raise()),
-        ("yaml", '"string"', does_not_raise()),
-        ("yaml", "string", does_not_raise()),
-        ("yaml", "1", does_not_raise()),
-        ("yaml", 1, does_not_raise()),
-        ("yaml", "1.1", does_not_raise()),
-        ("yaml", 1.1, does_not_raise()),
-        ("yaml", "true", does_not_raise()),
-        ("yaml", True, does_not_raise()),
-        ("yaml", "false", does_not_raise()),
-        ("yaml", False, does_not_raise()),
-        ("yaml", "{}", does_not_raise()),
-        ("yaml", {}, does_not_raise()),
-        ("yaml", "[]", does_not_raise()),
-        ("yaml", [], does_not_raise()),
-        ("yaml", "null", does_not_raise()),
-        ("yaml", None, does_not_raise()),
+        ({"type": "str", "default": "string"}, does_not_raise()),
+        ({"type": "str", "default": "1.0"}, does_not_raise()),
+        ({"type": "str", "default": 1.0}, does_not_raise()),
+        ({"type": "str", "default": None}, pytest.raises(TypeError)),
+        (
+            {
+                "type": "str",
+                "default": "",
+                "validator": "[% if q|length < 3 %]too short[% endif %]",
+            },
+            pytest.raises(ValueError),
+        ),
+        (
+            {
+                "type": "str",
+                "default": "",
+                "secret": True,
+                "validator": "[% if q|length < 3 %]too short[% endif %]",
+            },
+            pytest.raises(ValueError),
+        ),
+        ({"type": "int", "default": 1}, does_not_raise()),
+        ({"type": "int", "default": 1.0}, does_not_raise()),
+        ({"type": "int", "default": "1"}, does_not_raise()),
+        ({"type": "int", "default": "1.0"}, pytest.raises(ValueError)),
+        ({"type": "int", "default": "no-int"}, pytest.raises(ValueError)),
+        ({"type": "int", "default": None}, pytest.raises(TypeError)),
+        ({"type": "int", "default": {}}, pytest.raises(TypeError)),
+        ({"type": "int", "default": []}, pytest.raises(TypeError)),
+        ({"type": "float", "default": 1.1}, does_not_raise()),
+        ({"type": "float", "default": 1}, does_not_raise()),
+        ({"type": "float", "default": "1.1"}, does_not_raise()),
+        ({"type": "float", "default": "no-float"}, pytest.raises(ValueError)),
+        ({"type": "float", "default": None}, pytest.raises(TypeError)),
+        ({"type": "float", "default": {}}, pytest.raises(TypeError)),
+        ({"type": "float", "default": []}, pytest.raises(TypeError)),
+        ({"type": "bool", "default": True}, does_not_raise()),
+        ({"type": "bool", "default": False}, does_not_raise()),
+        ({"type": "bool", "default": "y"}, does_not_raise()),
+        ({"type": "bool", "default": "n"}, does_not_raise()),
+        ({"type": "bool", "default": None}, pytest.raises(TypeError)),
     ],
 )
 def test_validate_default_value(
     tmp_path_factory: pytest.TempPathFactory,
-    type_name: str,
-    default: Any,
+    spec: AnyByStrDict,
     expected: ContextManager[None],
 ) -> None:
     src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
-    build_file_tree(
-        {
-            (src / "copier.yml"): yaml.dump(
-                {
-                    "q": {
-                        "type": type_name,
-                        "default": default,
-                    }
-                }
-            )
-        }
-    )
+    build_file_tree({(src / "copier.yml"): yaml.dump({"q": spec})})
     with expected:
         copier.run_copy(str(src), dst, defaults=True)
 


----

problem_statement: Bug(?) in validation of secrets
### Describe the problem

Since the new release `9.8.0` secret field with a validator leads to failing project generation.

This seems to be caused by the newly added enforcement of input validation (if I get the release notes right).

The use case is that one has:
* a secret string field;
* with an empty-string default (because a secret requires a default and because for anything non-empty a user will not be able to see the value);
* with a validator (e.g. non-empty value as in the example template, but it could be also a check on the format of the secret, e.g. that a gitlab PAT has the expected prefix)

This scenario leads to project generation failing with an trace (the same template works fine with `9.7.1`), see logs.

### Template

https://github.com/mlisovyi/tmp-copier-980-secret-with-validation

### To Reproduce

1. pip install "copier==9.8.0"
2. Run `copier copy git@github.com:mlisovyi/tmp-copier-980-secret-with-validation.git .`


### Logs

```console
Traceback (most recent call last):
  File "/path/to/venv/.venv/bin/copier", line 8, in <module>
    sys.exit(CopierApp.run())
  File "/path/to/venv/.venv/lib/python3.10/site-packages/plumbum/cli/application.py", line 640, in run
    inst, retcode = subapp.run(argv, exit=False)
  File "/path/to/venv/.venv/lib/python3.10/site-packages/plumbum/cli/application.py", line 635, in run
    retcode = inst.main(*tailargs)
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_cli.py", line 284, in main
    return _handle_exceptions(inner)
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_cli.py", line 71, in _handle_exceptions
    method()
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_cli.py", line 275, in inner
    with self._worker(
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_main.py", line 271, in __exit__
    raise value
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_cli.py", line 282, in inner
    worker.run_copy()
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_main.py", line 95, in _wrapper
    return func(*args, **kwargs)
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_main.py", line 1029, in run_copy
    self._ask()
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_main.py", line 599, in _ask
    [question.get_questionary_structure()],
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_user_data.py", line 398, in get_questionary_structure
    default = self.get_default_rendered()
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_user_data.py", line 297, in get_default_rendered
    default = self.get_default()
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_user_data.py", line 285, in get_default
    self.validate_answer(result)
  File "/path/to/venv/.venv/lib/python3.10/site-packages/copier/_user_data.py", line 455, in validate_answer
    raise ValueError(
ValueError: Validation error for question 'secret_token': Secret token cannot be empty.
```

### Expected behavior

Validator of the (required) default value for a secret has to be skipped

### Screenshots/screencasts/logs

_No response_

### Operating system

macOS

### Operating system distribution and version

Doesn't matter- also observed on Windows

### Copier version

9.8.0

### Python version

3.10 (but also observed on 3.11)

### Installation method

pip+pypi

### Additional context

_No response_

date: 2025-07-30
patch: diff --git a/copier/main.py b/copier/main.py
index eca5e48..ba54947 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -875,6 +875,13 @@ class Worker:
                 src_path=self.subproject.template.url,  # type: ignore[union-attr]
                 vcs_ref=self.subproject.template.commit,  # type: ignore[union-attr]
             ) as old_worker:
+                # HACK: Remove the validator from a secret question when replaying
+                # a fresh copy using the old template because only the default value
+                # is available which may not pass validation.
+                assert old_worker.template is not self.template
+                for q in old_worker.template.questions_data.values():
+                    if q.get("secret", False):
+                        q.pop("validator", None)
                 old_worker.run_copy()
             # Run pre-migration tasks
             self._execute_tasks(

test_patch: diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py
index 81a2019..8f7b6d4 100644
--- a/tests/test_updatediff.py
+++ b/tests/test_updatediff.py
@@ -16,6 +16,7 @@ from copier.cli import CopierApp
 from copier.errors import UserMessageError
 from copier.main import Worker, run_copy, run_update
 from copier.tools import normalize_git_path
+from copier.user_data import load_answersfile_data
 
 from .helpers import (
     BRACKET_ENVOPS_JSON,
@@ -1293,3 +1294,44 @@ def test_update_with_new_file_in_template_and_project_via_migration(
         >>>>>>> after updating
         """
     )
+
+
+
+def test_disable_secret_validator_on_replay(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    build_file_tree(
+        {
+            src / "copier.yml": (
+                """                token:
+                    type: str
+                    secret: true
+                    default: ""
+                    validator: "{% if token == '' %}Must not be empty{% endif %}"
+                """
+            ),
+            src / "{{ _copier_conf.answers_file }}.jinja": (
+                "{{ _copier_answers|to_nice_yaml }}"
+            ),
+            src / ".gitignore": ".env",
+            src / ".env.jinja": "TOKEN={{ token }}",
+        }
+    )
+    with local.cwd(src):
+        git_init("v1")
+        git("tag", "v1")
+
+    run_copy(str(src), dst, data={"token": "$3cr3t"})
+    answers = load_answersfile_data(dst)
+    assert answers == {"_src_path": str(src), "_commit": "v1"}
+    assert (dst / ".env").read_text() == "TOKEN=$3cr3t"
+
+    with local.cwd(dst):
+        git_init("v1")
+
+    run_update(dst, data={"token": "$up3r-$3cr3t"}, overwrite=True)
+    answers = load_answersfile_data(dst)
+    assert answers == {"_src_path": str(src), "_commit": "v1"}
+    assert (dst / ".env").read_text() == "TOKEN=$up3r-$3cr3t"