Index: django/db/models/base.py
===================================================================
--- django/db/models/base.py	(revision 960)
+++ django/db/models/base.py	(working copy)
@@ -335,7 +335,13 @@
 
     def _get_FIELD_display(self, field):
         value = getattr(self, field.attname)
-        return force_unicode(dict(field.choices).get(value, value), strings_only=True)
+        flatchoices = []
+        for choice in field.choices:
+            if type(choice[1]) in (list, tuple):
+                flatchoices.extend(choice[1])
+            else:
+                flatchoices.append(choice)
+        return force_unicode(dict(flatchoices).get(value, value), strings_only=True)
 
     def _get_next_or_previous_by_FIELD(self, field, is_next, **kwargs):
         qn = connection.ops.quote_name
Index: django/newforms/fields.py
===================================================================
--- django/newforms/fields.py	(revision 960)
+++ django/newforms/fields.py	(working copy)
@@ -582,7 +582,13 @@
         value = smart_unicode(value)
         if value == u'':
             return value
-        valid_values = set([smart_unicode(k) for k, v in self.choices])
+        valid_values = [] 
+        for k, v in self.choices: 
+            if type(v) in (tuple, list): 
+                valid_values.extend([k2[0] for k2 in v]) 
+            else:
+                valid_values.append(k) 
+        valid_values = set([smart_unicode(v) for v in valid_values])        
         if value not in valid_values:
             raise ValidationError(self.error_messages['invalid_choice'] % {'value': value})
         return value
@@ -607,7 +613,13 @@
             raise ValidationError(self.error_messages['invalid_list'])
         new_value = [smart_unicode(val) for val in value]
         # Validate that each value in the value list is in self.choices.
-        valid_values = set([smart_unicode(k) for k, v in self.choices])
+        valid_values = [] 
+        for k, v in self.choices: 
+            if type(v) in (tuple, list): 
+                valid_values.extend([k2[0] for k2 in v]) 
+            else:
+                valid_values.append(k) 
+        valid_values = set([smart_unicode(v) for v in valid_values])        
         for val in new_value:
             if val not in valid_values:
                 raise ValidationError(self.error_messages['invalid_choice'] % {'value': val})
Index: django/newforms/widgets.py
===================================================================
--- django/newforms/widgets.py	(revision 960)
+++ django/newforms/widgets.py	(working copy)
@@ -215,9 +215,19 @@
         # Normalize to string.
         str_value = force_unicode(value)
         for option_value, option_label in chain(self.choices, choices):
-            option_value = force_unicode(option_value)
-            selected_html = (option_value == str_value) and u' selected="selected"' or ''
-            output.append(u'<option value="%s"%s>%s</option>' % (
+            if type(option_label) in (list, tuple):
+                output.append(u'<optgroup label="%s">' % escape(force_unicode(option_value)))
+                for group_option_value, group_option_label in option_label:
+                    group_option_value = force_unicode(group_option_value)
+                    selected_html = (group_option_value == str_value) and u' selected="selected"' or ''
+                    output.append(u'<option value="%s"%s>%s</option>' % (
+                        escape(group_option_value), selected_html,
+                        conditional_escape(force_unicode(group_option_label))))
+                output.append(u'</optgroup>')
+            else:
+                option_value = force_unicode(option_value)
+                selected_html = (option_value == str_value) and u' selected="selected"' or ''
+                output.append(u'<option value="%s"%s>%s</option>' % (
                     escape(option_value), selected_html,
                     conditional_escape(force_unicode(option_label))))
         output.append(u'</select>')
Index: tests/regressiontests/forms/widgets.py
===================================================================
--- tests/regressiontests/forms/widgets.py	(revision 960)
+++ tests/regressiontests/forms/widgets.py	(working copy)
@@ -419,6 +419,33 @@
 <option value="4">4</option>
 </select>
 
+Choices can be nested one level in order to create HTML optgroups:
+>>> w = Select(choices=(('outer1', 'Outer 1'), ('Group 1', (('inner1', 'Inner 1'), ('inner2', 'Inner 2')))))
+>>> print w.render('nestchoice', None)
+<select name="nestchoice">
+<option value="outer1">Outer 1</option>
+<optgroup label="Group 1">
+<option value="inner1">Inner 1</option>
+<option value="inner2">Inner 2</option>
+</optgroup>
+</select>
+>>> print w.render('nestchoice', 'outer1')
+<select name="nestchoice">
+<option value="outer1" selected="selected">Outer 1</option>
+<optgroup label="Group 1">
+<option value="inner1">Inner 1</option>
+<option value="inner2">Inner 2</option>
+</optgroup>
+</select>
+>>> print w.render('nestchoice', 'inner1')
+<select name="nestchoice">
+<option value="outer1">Outer 1</option>
+<optgroup label="Group 1">
+<option value="inner1" selected="selected">Inner 1</option>
+<option value="inner2">Inner 2</option>
+</optgroup>
+</select>
+
 # NullBooleanSelect Widget ####################################################
 
 >>> w = NullBooleanSelect()
Index: docs/newforms.txt
===================================================================
--- docs/newforms.txt	(revision 960)
+++ docs/newforms.txt	(working copy)
@@ -1229,8 +1229,12 @@
     * Error message keys: ``required``, ``invalid_choice``
 
 Takes one extra argument, ``choices``, which is an iterable (e.g., a list or
-tuple) of 2-tuples to use as choices for this field.
+tuple) of 2-tuples to use as choices for this field. This argument accepts
+the same formats as the ``choices`` argument to a model field. See the 
+`model API documentation on choices`_ for more details.
 
+.. _model API documentation on choices: ../model-api#choices
+
 ``DateField``
 ~~~~~~~~~~~~~
 
Index: docs/model-api.txt
===================================================================
--- docs/model-api.txt	(revision 960)
+++ docs/model-api.txt	(working copy)
@@ -568,6 +568,29 @@
     class Foo(models.Model):
         gender = models.CharField(max_length=1, choices=GENDER_CHOICES)
 
+You can also collect your available choices into named groups, which will
+be used for display purposes::
+
+    MEDIA_CHOICES = (
+        ('Audio', (
+                ('vinyl', 'Vinyl'),
+                ('cd', 'CD'),
+            )
+        ),
+        ('Video', (
+                ('vhs', 'VHS Tape'),
+                ('dvd', 'DVD'),
+            )
+        ),
+        ('unknown', 'Unknown'),
+    )
+
+The first element in each tuple is the name to apply to the group. The 
+second element is an iterable of 2-tuples, with each 2-tuple containing
+a value and a human-readable name for an option. Grouped options may be 
+combined with ungrouped options within a single list (such as the 
+`unknown` option in this example).
+
 For each model field that has ``choices`` set, Django will add a method to
 retrieve the human-readable name for the field's current value. See
 `get_FOO_display`_ in the database API documentation.
